Integration of edx_proctoring into the LMS
This commit is contained in:
@@ -28,7 +28,7 @@ def register_proctored_exams(course_key):
|
||||
This is typically called on a course published signal. The course is examined for sequences
|
||||
that are marked as timed exams. Then these are registered with the edx-proctoring
|
||||
subsystem. Likewise, if formerly registered exams are unmarked, then those
|
||||
registred exams are marked as inactive
|
||||
registered exams are marked as inactive
|
||||
"""
|
||||
|
||||
if not settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'):
|
||||
@@ -76,6 +76,7 @@ def register_proctored_exams(course_key):
|
||||
exam_name=timed_exam.display_name,
|
||||
time_limit_mins=timed_exam.default_time_limit_minutes,
|
||||
is_proctored=timed_exam.is_proctored_enabled,
|
||||
is_practice_exam=timed_exam.is_practice_exam,
|
||||
is_active=True
|
||||
)
|
||||
msg = 'Updated timed exam {exam_id}'.format(exam_id=exam['id'])
|
||||
@@ -87,6 +88,7 @@ def register_proctored_exams(course_key):
|
||||
exam_name=timed_exam.display_name,
|
||||
time_limit_mins=timed_exam.default_time_limit_minutes,
|
||||
is_proctored=timed_exam.is_proctored_enabled,
|
||||
is_practice_exam=timed_exam.is_practice_exam,
|
||||
is_active=True
|
||||
)
|
||||
msg = 'Created new timed exam {exam_id}'.format(exam_id=exam_id)
|
||||
|
||||
@@ -862,7 +862,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
xblock_info.update({
|
||||
"is_proctored_enabled": xblock.is_proctored_enabled,
|
||||
"is_time_limited": xblock.is_time_limited,
|
||||
"default_time_limit_minutes": xblock.default_time_limit_minutes
|
||||
"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.
|
||||
|
||||
@@ -352,7 +352,6 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
|
||||
XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False)
|
||||
XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY)
|
||||
|
||||
|
||||
################# PROCTORING CONFIGURATION ##################
|
||||
|
||||
PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER)
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"SUBDOMAIN_BRANDING": false,
|
||||
"SUBDOMAIN_COURSE_LISTINGS": false,
|
||||
"ALLOW_ALL_ADVANCED_COMPONENTS": true,
|
||||
"ENABLE_CONTENT_LIBRARIES": true
|
||||
"ENABLE_CONTENT_LIBRARIES": true,
|
||||
"ENABLE_PROCTORED_EXAMS": true
|
||||
},
|
||||
"FEEDBACK_SUBMISSION_EMAIL": "",
|
||||
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
||||
|
||||
@@ -101,6 +101,8 @@ FEATURES['ENABLE_VIDEO_BUMPER'] = True # Enable video bumper in Studio settings
|
||||
########################### Entrance Exams #################################
|
||||
FEATURES['ENTRANCE_EXAMS'] = True
|
||||
|
||||
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
|
||||
|
||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||
YOUTUBE_PORT = 9080
|
||||
YOUTUBE['API'] = "http://127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
|
||||
|
||||
@@ -173,6 +173,9 @@ 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,
|
||||
|
||||
@@ -775,6 +778,9 @@ INSTALLED_APPS = (
|
||||
'openedx.core.djangoapps.credit',
|
||||
|
||||
'xblock_django',
|
||||
|
||||
# edX Proctoring
|
||||
'edx_proctoring',
|
||||
)
|
||||
|
||||
|
||||
@@ -1040,11 +1046,10 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60
|
||||
|
||||
DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended']
|
||||
|
||||
|
||||
#### PROCTORING CONFIGURATION DEFAULTS
|
||||
|
||||
PROCTORING_BACKEND_PROVIDER = {
|
||||
'class': 'edx_proctoring.backends.NullBackendProvider',
|
||||
'class': 'edx_proctoring.backends.null.NullBackendProvider',
|
||||
'options': {},
|
||||
}
|
||||
PROCTORING_SETTINGS = {}
|
||||
|
||||
@@ -620,6 +620,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut
|
||||
has_explicit_staff_lock: true,
|
||||
staff_only_message: true,
|
||||
"is_time_limited": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored_enabled": true,
|
||||
"default_time_limit_minutes": 150
|
||||
}, [
|
||||
@@ -706,6 +707,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut
|
||||
"start":"2014-07-09T00:00:00.000Z",
|
||||
"due":"2014-07-10T00:00:00.000Z",
|
||||
"is_time_limited": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored_enabled": true,
|
||||
"default_time_limit_minutes": 150
|
||||
}
|
||||
@@ -740,6 +742,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut
|
||||
expect($("#staff_lock").is(":checked")).toBe(true);
|
||||
expect($("#id_timed_examination").is(":checked")).toBe(true);
|
||||
expect($("#id_exam_proctoring").is(":checked")).toBe(true);
|
||||
expect($("#is_practice_exam").is(":checked")).toBe(false);
|
||||
expect($("#id_time_limit").val()).toBe("02:30");
|
||||
});
|
||||
|
||||
|
||||
@@ -275,11 +275,17 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
||||
event.preventDefault();
|
||||
if (!$(event.currentTarget).is(':checked')) {
|
||||
this.$('#id_exam_proctoring').attr('checked', false);
|
||||
this.$('#id_time_limit').val('00:30');
|
||||
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');
|
||||
}
|
||||
@@ -289,11 +295,17 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
||||
AbstractEditor.prototype.afterRender.call(this);
|
||||
this.$('input.time').timepicker({
|
||||
'timeFormat' : 'H:i',
|
||||
'minTime': '00:30',
|
||||
'maxTime': '05:00',
|
||||
'forceRoundTime': false
|
||||
});
|
||||
this.setExamTime(this.model.get('default_time_limit_minutes'));
|
||||
this.setExamTmePreference(this.model.get('is_time_limited'));
|
||||
this.setExamProctoring(this.model.get('is_proctored_enabled'));
|
||||
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);
|
||||
@@ -307,14 +319,18 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
||||
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);
|
||||
return pattern.test(time_limit) && time_limit !== "00:00";
|
||||
},
|
||||
getExamTimeLimit: function () {
|
||||
return this.$('#id_time_limit').val();
|
||||
@@ -338,6 +354,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
||||
var time_limit = this.getExamTimeLimit();
|
||||
return {
|
||||
metadata: {
|
||||
'is_practice_exam': this.isPracticeExam(),
|
||||
'is_time_limited': this.isExamTimeEnabled(),
|
||||
'is_proctored_enabled': this.isExamProctoringEnabled(),
|
||||
'default_time_limit_minutes': this.convertTimeLimitToMinutes(time_limit)
|
||||
|
||||
@@ -24,6 +24,17 @@
|
||||
<p class='field-message'><%- gettext('Students see warnings when 20% and 5% of the allotted time remains. In certain cases, students can be granted allowances that give them extra time to complete the exam.') %></p>
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="list-fields list-input">
|
||||
<li class="field field-checkbox checkbox-cosmetic">
|
||||
<input type="checkbox" id="id_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" />
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"""
|
||||
xModule implementation of a learning sequence
|
||||
"""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
import json
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Integer, Scope, Boolean
|
||||
from xblock.fragment import Fragment
|
||||
from pkg_resources import resource_string
|
||||
@@ -91,7 +98,9 @@ class ProctoringFields(object):
|
||||
)
|
||||
|
||||
|
||||
class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disable=abstract-method
|
||||
@XBlock.wants('proctoring')
|
||||
@XBlock.wants('credit')
|
||||
class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
''' Layout module which lays out content in a temporal sequence
|
||||
'''
|
||||
js = {
|
||||
@@ -141,6 +150,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disa
|
||||
else:
|
||||
self.position = 1
|
||||
return json.dumps({'success': True})
|
||||
|
||||
raise NotFoundError('Unexpected dispatch type')
|
||||
|
||||
def student_view(self, context):
|
||||
@@ -154,6 +164,16 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disa
|
||||
|
||||
fragment = Fragment()
|
||||
|
||||
# Is this sequential part of a timed or proctored exam?
|
||||
if self.is_time_limited:
|
||||
view_html = self._time_limited_student_view(context)
|
||||
|
||||
# Do we have an alternate rendering
|
||||
# from the edx_proctoring subsystem?
|
||||
if view_html:
|
||||
fragment.add_content(view_html)
|
||||
return fragment
|
||||
|
||||
for child in self.get_display_items():
|
||||
progress = child.get_progress()
|
||||
rendered_child = child.render(STUDENT_VIEW, context)
|
||||
@@ -181,10 +201,72 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disa
|
||||
'ajax_url': self.system.ajax_url,
|
||||
}
|
||||
|
||||
fragment.add_content(self.system.render_template('seq_module.html', params))
|
||||
fragment.add_content(self.system.render_template("seq_module.html", params))
|
||||
|
||||
return fragment
|
||||
|
||||
def _time_limited_student_view(self, context):
|
||||
"""
|
||||
Delegated rendering of a student view when in a time
|
||||
limited view. This ultimately calls down into edx_proctoring
|
||||
pip installed djangoapp
|
||||
"""
|
||||
|
||||
# None = no overridden view rendering
|
||||
view_html = None
|
||||
|
||||
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
|
||||
feature_enabled = (
|
||||
proctoring_service and
|
||||
credit_service and
|
||||
proctoring_service.is_feature_enabled()
|
||||
)
|
||||
if feature_enabled:
|
||||
user_id = self.runtime.user_id
|
||||
user_role_in_course = 'staff' if self.runtime.user_is_staff else 'student'
|
||||
course_id = self.runtime.course_id
|
||||
content_id = self.location
|
||||
|
||||
context = {
|
||||
'display_name': self.display_name,
|
||||
'default_time_limit_mins': (
|
||||
self.default_time_limit_minutes if
|
||||
self.default_time_limit_minutes else 0
|
||||
),
|
||||
'is_practice_exam': self.is_practice_exam
|
||||
}
|
||||
|
||||
# inject the user's credit requirements and fulfillments
|
||||
if credit_service:
|
||||
credit_state = credit_service.get_credit_state(user_id, course_id)
|
||||
if credit_state:
|
||||
context.update({
|
||||
'credit_state': credit_state
|
||||
})
|
||||
|
||||
# See if the edx-proctoring subsystem wants to present
|
||||
# a special view to the student rather
|
||||
# than the actual sequence content
|
||||
#
|
||||
# This will return None if there is no
|
||||
# overridden view to display given the
|
||||
# current state of the user
|
||||
view_html = proctoring_service.get_student_view(
|
||||
user_id=user_id,
|
||||
course_id=course_id,
|
||||
content_id=content_id,
|
||||
context=context,
|
||||
user_role=user_role_in_course
|
||||
)
|
||||
|
||||
return view_html
|
||||
|
||||
def get_icon_class(self):
|
||||
child_classes = set(child.get_icon_class()
|
||||
for child in self.get_children())
|
||||
|
||||
@@ -104,6 +104,36 @@ class CoursewarePage(CoursePage):
|
||||
"""
|
||||
return self.q(css='.chapter ul li.active a').attrs('href')[0]
|
||||
|
||||
@property
|
||||
def can_start_proctored_exam(self):
|
||||
"""
|
||||
Returns True if the timed/proctored exam timer bar is visible on the courseware.
|
||||
"""
|
||||
return self.q(css='button.start-timed-exam[data-start-immediately="false"]').is_present()
|
||||
|
||||
def start_timed_exam(self):
|
||||
"""
|
||||
clicks the start this timed exam link
|
||||
"""
|
||||
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 start_proctored_exam(self):
|
||||
"""
|
||||
clicks the start this timed exam link
|
||||
"""
|
||||
self.q(css='button.start-timed-exam[data-start-immediately="false"]').first.click()
|
||||
|
||||
# Wait for the unique exam code to appear.
|
||||
# elf.wait_for_element_presence(".proctored-exam-code", "unique exam code")
|
||||
|
||||
@property
|
||||
def is_timer_bar_present(self):
|
||||
"""
|
||||
Returns True if the timed/proctored exam timer bar is visible on the courseware.
|
||||
"""
|
||||
return self.q(css=".proctored_exam_status .exam-timer").is_present()
|
||||
|
||||
|
||||
class CoursewareSequentialTabPage(CoursePage):
|
||||
"""
|
||||
|
||||
@@ -66,6 +66,15 @@ class InstructorDashboardPage(CoursePage):
|
||||
certificates_section.wait_for_page()
|
||||
return certificates_section
|
||||
|
||||
def select_proctoring(self):
|
||||
"""
|
||||
Selects the proctoring tab and returns the ProctoringSection
|
||||
"""
|
||||
self.q(css='a[data-section=proctoring]').first.click()
|
||||
proctoring_section = ProctoringPage(self.browser)
|
||||
proctoring_section.wait_for_ajax()
|
||||
return proctoring_section
|
||||
|
||||
@staticmethod
|
||||
def get_asset_path(file_name):
|
||||
"""
|
||||
@@ -105,6 +114,40 @@ class MembershipPage(PageObject):
|
||||
return MembershipPageAutoEnrollSection(self.browser)
|
||||
|
||||
|
||||
class ProctoringPage(PageObject):
|
||||
"""
|
||||
Proctoring section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='a[data-section=proctoring].active-section').present
|
||||
|
||||
def select_allowance_section(self):
|
||||
"""
|
||||
Expand the allowance section
|
||||
"""
|
||||
allowance_section = ProctoringPageAllowanceSection(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]",
|
||||
"Allowance Section")
|
||||
allowance_section.wait_for_page()
|
||||
return allowance_section
|
||||
|
||||
def select_exam_attempts_section(self):
|
||||
"""
|
||||
Expand the Student Attempts Section
|
||||
"""
|
||||
exam_attempts_section = ProctoringPageAttemptsSection(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]",
|
||||
"Attempts Section")
|
||||
exam_attempts_section.wait_for_page()
|
||||
return exam_attempts_section
|
||||
|
||||
|
||||
class CohortManagementSection(PageObject):
|
||||
"""
|
||||
The Cohort Management section of the Instructor dashboard.
|
||||
@@ -707,6 +750,55 @@ class MembershipPageAutoEnrollSection(PageObject):
|
||||
self.click_upload_file_button()
|
||||
|
||||
|
||||
class ProctoringPageAllowanceSection(PageObject):
|
||||
"""
|
||||
Allowance section of the Instructor dashboard's Proctoring tab.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]").present
|
||||
|
||||
@property
|
||||
def is_add_allowance_button_visible(self):
|
||||
"""
|
||||
Returns True if the Add Allowance button is present.
|
||||
"""
|
||||
return self.q(css="a#add-allowance").present
|
||||
|
||||
|
||||
class ProctoringPageAttemptsSection(PageObject):
|
||||
"""
|
||||
Exam Attempts section of the Instructor dashboard's Proctoring 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
|
||||
|
||||
@property
|
||||
def is_search_text_field_visible(self):
|
||||
"""
|
||||
Returns True if the search field is present
|
||||
"""
|
||||
return self.q(css="#search_attempt_id").present
|
||||
|
||||
@property
|
||||
def is_student_attempt_visible(self):
|
||||
"""
|
||||
Returns True if a row with the Student's attempt is present
|
||||
"""
|
||||
return self.q(css="a.remove-attempt").present
|
||||
|
||||
def remove_student_attempt(self):
|
||||
"""
|
||||
Clicks the "x" to remove the Student's attempt.
|
||||
"""
|
||||
with self.handle_alert(confirm=True):
|
||||
self.q(css="a.remove-attempt").first.click()
|
||||
self.wait_for_element_absence("a.remove-attempt", "exam attempt")
|
||||
|
||||
|
||||
class DataDownloadPage(PageObject):
|
||||
"""
|
||||
Data Download section of the Instructor dashboard.
|
||||
|
||||
@@ -513,6 +513,60 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
||||
"""
|
||||
self.reindex_button.click()
|
||||
|
||||
def open_exam_settings_dialog(self):
|
||||
"""
|
||||
clicks on the settings button of subsection.
|
||||
"""
|
||||
self.q(css=".subsection-header-actions .configure-button").first.click()
|
||||
|
||||
def change_problem_release_date_in_studio(self):
|
||||
"""
|
||||
Sets a new start date
|
||||
"""
|
||||
self.q(css=".subsection-header-actions .configure-button").first.click()
|
||||
self.q(css="#start_date").fill("01/01/2030")
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def make_exam_proctored(self):
|
||||
"""
|
||||
Makes a Proctored exam.
|
||||
"""
|
||||
self.q(css="#id_timed_examination").first.click()
|
||||
self.q(css="#id_exam_proctoring").first.click()
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def make_exam_timed(self):
|
||||
"""
|
||||
Makes a timed exam.
|
||||
"""
|
||||
self.q(css="#id_timed_examination").first.click()
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
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:
|
||||
return False
|
||||
|
||||
# The time limit field
|
||||
if not self.q(css="#id_time_limit").present:
|
||||
return False
|
||||
|
||||
# The Practice exam checkbox
|
||||
if not self.q(css="#id_practice_exam").present:
|
||||
return False
|
||||
|
||||
# The Proctored exam checkbox
|
||||
if not self.q(css="#id_exam_proctoring").present:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def bottom_add_section_button(self):
|
||||
"""
|
||||
|
||||
@@ -6,11 +6,15 @@ import time
|
||||
|
||||
from ..helpers import UniqueCourseTest
|
||||
from ...pages.studio.auto_auth import AutoAuthPage
|
||||
from ...pages.lms.create_mode import ModeCreationPage
|
||||
from ...pages.studio.overview import CourseOutlinePage
|
||||
from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage
|
||||
from ...pages.lms.course_nav import CourseNavPage
|
||||
from ...pages.lms.problem import ProblemPage
|
||||
from ...pages.common.logout import LogoutPage
|
||||
from ...pages.lms.track_selection import TrackSelectionPage
|
||||
from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage
|
||||
from ...pages.lms.dashboard import DashboardPage
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
|
||||
|
||||
@@ -63,14 +67,6 @@ class CoursewareTest(UniqueCourseTest):
|
||||
self.problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 1')
|
||||
|
||||
def _change_problem_release_date_in_studio(self):
|
||||
"""
|
||||
|
||||
"""
|
||||
self.course_outline.q(css=".subsection-header-actions .configure-button").first.click()
|
||||
self.course_outline.q(css="#start_date").fill("01/01/2030")
|
||||
self.course_outline.q(css=".action-save").first.click()
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
@@ -94,10 +90,7 @@ class CoursewareTest(UniqueCourseTest):
|
||||
self.course_outline.visit()
|
||||
|
||||
# Set release date for subsection in future.
|
||||
self._change_problem_release_date_in_studio()
|
||||
|
||||
# Wait for 2 seconds to save new date.
|
||||
time.sleep(2)
|
||||
self.course_outline.change_problem_release_date_in_studio()
|
||||
|
||||
# Logout and login as a student.
|
||||
LogoutPage(self.browser).visit()
|
||||
@@ -109,6 +102,158 @@ class CoursewareTest(UniqueCourseTest):
|
||||
self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 2')
|
||||
|
||||
|
||||
class ProctoredExamTest(UniqueCourseTest):
|
||||
"""
|
||||
Test courseware.
|
||||
"""
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(ProctoredExamTest, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
self.course_outline = CourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
# Install a course with sections/problems, tabs, updates, and handouts
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
course_fix.add_advanced_settings({
|
||||
"enable_proctored_exams": {"value": "true"}
|
||||
})
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1')
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.track_selection_page = TrackSelectionPage(self.browser, self.course_id)
|
||||
self.payment_and_verification_flow = PaymentAndVerificationFlow(self.browser, self.course_id)
|
||||
self.immediate_verification_page = PaymentAndVerificationFlow(
|
||||
self.browser, self.course_id, entry_point='verify-now'
|
||||
)
|
||||
self.upgrade_page = PaymentAndVerificationFlow(self.browser, self.course_id, entry_point='upgrade')
|
||||
self.fake_payment_page = FakePaymentPage(self.browser, self.course_id)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
self.problem_page = ProblemPage(self.browser)
|
||||
|
||||
# Add a verified mode to the course
|
||||
ModeCreationPage(
|
||||
self.browser, self.course_id, mode_slug=u'verified', mode_display_name=u'Verified Certificate',
|
||||
min_price=10, suggested_prices='10,20'
|
||||
).visit()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
|
||||
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).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 test_can_create_proctored_exam_in_studio(self):
|
||||
"""
|
||||
Test that Proctored exam settings are visible in Studio.
|
||||
"""
|
||||
|
||||
# Given that I am a staff member
|
||||
LogoutPage(self.browser).visit()
|
||||
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
|
||||
|
||||
# When I visit the course outline page in studio.
|
||||
self.course_outline.visit()
|
||||
|
||||
# And open the subsection edit dialog
|
||||
self.course_outline.open_exam_settings_dialog()
|
||||
|
||||
# Then I can view all settings related to Proctored and timed exams
|
||||
self.assertTrue(self.course_outline.proctoring_items_are_displayed())
|
||||
|
||||
def test_proctored_exam_flow(self):
|
||||
"""
|
||||
Test that staff can create a proctored exam.
|
||||
"""
|
||||
|
||||
# Given that I am a staff member on the exam settings section
|
||||
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 Make the exam proctored.
|
||||
self.course_outline.make_exam_proctored()
|
||||
|
||||
# And I login as a verified student.
|
||||
LogoutPage(self.browser).visit()
|
||||
self._login_as_a_verified_user()
|
||||
|
||||
# And visit the courseware as a verified student.
|
||||
self.courseware_page.visit()
|
||||
|
||||
# Then I can see an option to take the exam as a proctored exam.
|
||||
self.assertTrue(self.courseware_page.can_start_proctored_exam)
|
||||
|
||||
def test_timed_exam_flow(self):
|
||||
"""
|
||||
Test that staff can create a timed exam.
|
||||
"""
|
||||
|
||||
# Given that I am a staff member on the exam settings section
|
||||
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 Make the exam timed.
|
||||
self.course_outline.make_exam_timed()
|
||||
|
||||
# And I login as a verified student.
|
||||
LogoutPage(self.browser).visit()
|
||||
self._login_as_a_verified_user()
|
||||
|
||||
# And visit the courseware as a verified student.
|
||||
self.courseware_page.visit()
|
||||
|
||||
# And I start the timed exam
|
||||
self.courseware_page.start_timed_exam()
|
||||
|
||||
# Then I am taken to the exam with a timer bar showing
|
||||
self.assertTrue(self.courseware_page.is_timer_bar_present)
|
||||
|
||||
|
||||
class CoursewareMultipleVerticalsTest(UniqueCourseTest):
|
||||
"""
|
||||
Test courseware with multiple verticals
|
||||
|
||||
@@ -3,14 +3,23 @@
|
||||
End-to-end tests for the LMS Instructor Dashboard.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from nose.plugins.attrib import attr
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from ..helpers import UniqueCourseTest, get_modal_alert, EventsTestMixin
|
||||
from ...pages.common.logout import LogoutPage
|
||||
from ...pages.lms.auto_auth import AutoAuthPage
|
||||
from ...pages.studio.overview import CourseOutlinePage
|
||||
from ...pages.lms.create_mode import ModeCreationPage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.instructor_dashboard import InstructorDashboardPage
|
||||
from ...fixtures.course import CourseFixture
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...pages.lms.dashboard import DashboardPage
|
||||
from ...pages.lms.problem import ProblemPage
|
||||
from ...pages.lms.track_selection import TrackSelectionPage
|
||||
from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage
|
||||
|
||||
|
||||
class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest):
|
||||
@@ -107,6 +116,175 @@ 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.")
|
||||
|
||||
|
||||
class ProctoredExamsTest(BaseInstructorDashboardTest):
|
||||
"""
|
||||
End-to-end tests for Proctoring Sections of the Instructor Dashboard.
|
||||
"""
|
||||
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(ProctoredExamsTest, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
self.course_outline = CourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
course_fixture = CourseFixture(**self.course_info)
|
||||
course_fixture.add_advanced_settings({
|
||||
"enable_proctored_exams": {"value": "true"}
|
||||
})
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1')
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.track_selection_page = TrackSelectionPage(self.browser, self.course_id)
|
||||
self.payment_and_verification_flow = PaymentAndVerificationFlow(self.browser, self.course_id)
|
||||
self.immediate_verification_page = PaymentAndVerificationFlow(
|
||||
self.browser, self.course_id, entry_point='verify-now'
|
||||
)
|
||||
self.upgrade_page = PaymentAndVerificationFlow(self.browser, self.course_id, entry_point='upgrade')
|
||||
self.fake_payment_page = FakePaymentPage(self.browser, self.course_id)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
self.problem_page = ProblemPage(self.browser)
|
||||
|
||||
# Add a verified mode to the course
|
||||
ModeCreationPage(
|
||||
self.browser, self.course_id, mode_slug=u'verified', mode_display_name=u'Verified Certificate',
|
||||
min_price=10, suggested_prices='10,20'
|
||||
).visit()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
|
||||
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).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):
|
||||
"""
|
||||
Creates a proctored exam and makes the student attempt it so that
|
||||
the associated allowance and attempts are visible on the Instructor Dashboard.
|
||||
"""
|
||||
# Visit the course outline page in studio
|
||||
LogoutPage(self.browser).visit()
|
||||
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
|
||||
self.course_outline.visit()
|
||||
|
||||
#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._login_as_a_verified_user()
|
||||
self.courseware_page.visit()
|
||||
|
||||
# Start the proctored exam.
|
||||
self.courseware_page.start_proctored_exam()
|
||||
|
||||
def _create_a_timed_exam_and_attempt(self):
|
||||
"""
|
||||
Creates a timed exam and makes the student attempt it so that
|
||||
the associated allowance and attempts are visible on the Instructor Dashboard.
|
||||
"""
|
||||
# Visit the course outline page in studio
|
||||
LogoutPage(self.browser).visit()
|
||||
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
|
||||
self.course_outline.visit()
|
||||
|
||||
#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._login_as_a_verified_user()
|
||||
self.courseware_page.visit()
|
||||
|
||||
# Start the proctored exam.
|
||||
self.courseware_page.start_timed_exam()
|
||||
|
||||
def test_can_add_remove_allowance(self):
|
||||
"""
|
||||
Make sure that allowances can be added and removed.
|
||||
"""
|
||||
|
||||
# Given that an exam has been configured to be a proctored exam.
|
||||
self._create_a_proctored_exam_and_attempt()
|
||||
|
||||
# When I log in as an instructor,
|
||||
self.log_in_as_instructor()
|
||||
|
||||
# And visit the Allowance Section of Instructor Dashboard's Proctoring tab
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
allowance_section = instructor_dashboard_page.select_proctoring().select_allowance_section()
|
||||
|
||||
# Then I can add Allowance to that exam for a student
|
||||
self.assertTrue(allowance_section.is_add_allowance_button_visible)
|
||||
|
||||
def test_can_reset_attempts(self):
|
||||
"""
|
||||
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()
|
||||
|
||||
# And visit the Student Proctored Exam Attempts Section of Instructor Dashboard's Proctoring tab
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
exam_attempts_section = instructor_dashboard_page.select_proctoring().select_exam_attempts_section()
|
||||
|
||||
# Then I can see the search text field
|
||||
self.assertTrue(exam_attempts_section.is_search_text_field_visible)
|
||||
|
||||
# And I can see one attempt by a student.
|
||||
self.assertTrue(exam_attempts_section.is_student_attempt_visible)
|
||||
|
||||
# And I can remove the attempt by clicking the "x" at the end of the row.
|
||||
exam_attempts_section.remove_student_attempt()
|
||||
self.assertFalse(exam_attempts_section.is_student_attempt_visible)
|
||||
|
||||
|
||||
@attr('shard_5')
|
||||
class EntranceExamGradeTest(BaseInstructorDashboardTest):
|
||||
"""
|
||||
|
||||
@@ -78,6 +78,9 @@ from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
|
||||
from util import milestones_helpers
|
||||
from verify_student.services import ReverificationService
|
||||
|
||||
from edx_proctoring.services import ProctoringService
|
||||
from openedx.core.djangoapps.credit.services import CreditService
|
||||
|
||||
from .field_overrides import OverrideFieldData
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -178,13 +181,69 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
|
||||
section.url_name == active_section)
|
||||
|
||||
if not section.hide_from_toc:
|
||||
sections.append({'display_name': section.display_name_with_default,
|
||||
'url_name': section.url_name,
|
||||
'format': section.format if section.format is not None else '',
|
||||
'due': section.due,
|
||||
'active': active,
|
||||
'graded': section.graded,
|
||||
})
|
||||
section_context = {
|
||||
'display_name': section.display_name_with_default,
|
||||
'url_name': section.url_name,
|
||||
'format': section.format if section.format is not None else '',
|
||||
'due': section.due,
|
||||
'active': active,
|
||||
'graded': section.graded,
|
||||
}
|
||||
|
||||
#
|
||||
# Add in rendering context for proctored exams
|
||||
# if applicable
|
||||
#
|
||||
is_proctored_enabled = (
|
||||
getattr(section, 'is_proctored_enabled', False) and
|
||||
settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False)
|
||||
)
|
||||
if is_proctored_enabled:
|
||||
# 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
|
||||
# circular references (not sure which as edx_proctoring.api
|
||||
# doesn't import anything from edx-platform). Odd thing
|
||||
# is that running: manage.py lms runserver --settings=acceptance
|
||||
# works just fine, it's really a combination of Lettuce and the
|
||||
# 'harvest' management command
|
||||
#
|
||||
# One idea is that there is some coupling between
|
||||
# lettuce and the 'terrain' Djangoapps projects in /common
|
||||
# This would need more investigation
|
||||
from edx_proctoring.api import get_attempt_status_summary
|
||||
|
||||
#
|
||||
# call into edx_proctoring subsystem
|
||||
# to get relevant proctoring information regarding this
|
||||
# level of the courseware
|
||||
#
|
||||
# This will return None, if (user, course_id, content_id)
|
||||
# is not applicable
|
||||
#
|
||||
proctoring_attempt_context = None
|
||||
try:
|
||||
proctoring_attempt_context = get_attempt_status_summary(
|
||||
user.id,
|
||||
unicode(course.id),
|
||||
unicode(section.location)
|
||||
)
|
||||
except Exception, ex: # pylint: disable=broad-except
|
||||
# safety net in case something blows up in edx_proctoring
|
||||
# as this is just informational descriptions, it is better
|
||||
# to log and continue (which is safe) than to have it be an
|
||||
# unhandled exception
|
||||
log.exception(ex)
|
||||
|
||||
if proctoring_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,
|
||||
})
|
||||
|
||||
sections.append(section_context)
|
||||
toc_chapters.append({
|
||||
'display_name': chapter.display_name_with_default,
|
||||
'url_name': chapter.url_name,
|
||||
@@ -678,7 +737,9 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to
|
||||
'fs': FSService(),
|
||||
'field-data': field_data,
|
||||
'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff),
|
||||
"reverification": ReverificationService()
|
||||
"reverification": ReverificationService(),
|
||||
'proctoring': ProctoringService(),
|
||||
'credit': CreditService(),
|
||||
},
|
||||
get_user_role=lambda: get_user_role(user, course_id),
|
||||
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access
|
||||
|
||||
@@ -50,6 +50,20 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory, check_mongo_calls
|
||||
from xmodule.x_module import XModuleDescriptor, XModule, STUDENT_VIEW, CombinedSystem
|
||||
|
||||
from openedx.core.djangoapps.credit.models import CreditCourse
|
||||
from openedx.core.djangoapps.credit.api import (
|
||||
set_credit_requirements,
|
||||
set_credit_requirement_status
|
||||
)
|
||||
|
||||
from edx_proctoring.api import (
|
||||
create_exam,
|
||||
create_exam_attempt,
|
||||
update_attempt_status
|
||||
)
|
||||
from edx_proctoring.runtime import set_runtime_service
|
||||
from edx_proctoring.tests.test_services import MockCreditService
|
||||
|
||||
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
|
||||
@@ -682,6 +696,327 @@ class TestTOC(ModuleStoreTestCase):
|
||||
self.assertIn(toc_section, actual)
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
@ddt.ddt
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True})
|
||||
class TestProctoringRendering(ModuleStoreTestCase):
|
||||
"""Check the Table of Contents for a course"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up the initial mongo datastores
|
||||
"""
|
||||
super(TestProctoringRendering, self).setUp()
|
||||
self.course_key = self.create_toy_course()
|
||||
self.chapter = 'Overview'
|
||||
chapter_url = '%s/%s/%s' % ('/courses', self.course_key, self.chapter)
|
||||
factory = RequestFactory()
|
||||
self.request = factory.get(chapter_url)
|
||||
self.request.user = UserFactory()
|
||||
self.modulestore = self.store._get_modulestore_for_courselike(self.course_key) # pylint: disable=protected-access, attribute-defined-outside-init
|
||||
with self.modulestore.bulk_operations(self.course_key):
|
||||
self.toy_course = self.store.get_course(self.toy_loc, depth=2)
|
||||
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
self.toy_loc, self.request.user, self.toy_course, depth=2
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
('honor', False, None, None),
|
||||
(
|
||||
'honor',
|
||||
True,
|
||||
'eligible',
|
||||
{
|
||||
'status': 'eligible',
|
||||
'short_description': 'Ungraded Practice Exam',
|
||||
'suggested_icon': 'fa-lock',
|
||||
'in_completed_state': False
|
||||
}
|
||||
),
|
||||
(
|
||||
'honor',
|
||||
True,
|
||||
'submitted',
|
||||
{
|
||||
'status': 'submitted',
|
||||
'short_description': 'Practice Exam Completed',
|
||||
'suggested_icon': 'fa-check',
|
||||
'in_completed_state': True
|
||||
}
|
||||
),
|
||||
(
|
||||
'honor',
|
||||
True,
|
||||
'error',
|
||||
{
|
||||
'status': 'error',
|
||||
'short_description': 'Practice Exam Failed',
|
||||
'suggested_icon': 'fa-exclamation-triangle',
|
||||
'in_completed_state': True
|
||||
}
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
None,
|
||||
{
|
||||
'status': 'eligible',
|
||||
'short_description': 'Proctored Option Available',
|
||||
'suggested_icon': 'fa-lock',
|
||||
'in_completed_state': False
|
||||
}
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'declined',
|
||||
{
|
||||
'status': 'declined',
|
||||
'short_description': 'Taking As Open Exam',
|
||||
'suggested_icon': 'fa-unlock',
|
||||
'in_completed_state': False
|
||||
}
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'submitted',
|
||||
{
|
||||
'status': 'submitted',
|
||||
'short_description': 'Pending Session Review',
|
||||
'suggested_icon': 'fa-spinner fa-spin',
|
||||
'in_completed_state': True
|
||||
}
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'verified',
|
||||
{
|
||||
'status': 'verified',
|
||||
'short_description': 'Passed Proctoring',
|
||||
'suggested_icon': 'fa-check',
|
||||
'in_completed_state': True
|
||||
}
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'rejected',
|
||||
{
|
||||
'status': 'rejected',
|
||||
'short_description': 'Failed Proctoring',
|
||||
'suggested_icon': 'fa-exclamation-triangle',
|
||||
'in_completed_state': True
|
||||
}
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'error',
|
||||
{
|
||||
'status': 'error',
|
||||
'short_description': 'Failed Proctoring',
|
||||
'suggested_icon': 'fa-exclamation-triangle',
|
||||
'in_completed_state': True
|
||||
}
|
||||
),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_proctored_exam_toc(self, enrollment_mode, is_practice_exam,
|
||||
attempt_status, expected):
|
||||
"""
|
||||
Generate TOC for a course with a single chapter/sequence which contains proctored exam
|
||||
"""
|
||||
self._setup_test_data(enrollment_mode, is_practice_exam, attempt_status)
|
||||
|
||||
actual = render.toc_for_course(
|
||||
self.request.user,
|
||||
self.request,
|
||||
self.toy_course,
|
||||
self.chapter,
|
||||
'Toy_Videos',
|
||||
self.field_data_cache
|
||||
)
|
||||
section_actual = self._find_section(actual, 'Overview', 'Toy_Videos')
|
||||
|
||||
if expected:
|
||||
self.assertIn(expected, [section_actual['proctoring']])
|
||||
else:
|
||||
# we expect there not to be a 'proctoring' key in the dict
|
||||
self.assertNotIn('proctoring', section_actual)
|
||||
|
||||
@ddt.data(
|
||||
(
|
||||
'honor',
|
||||
True,
|
||||
None,
|
||||
'Would you like to take "Toy Videos" as a practice proctored exam?',
|
||||
True
|
||||
),
|
||||
(
|
||||
'honor',
|
||||
True,
|
||||
'submitted',
|
||||
'You have submitted this practice proctored exam',
|
||||
False
|
||||
),
|
||||
(
|
||||
'honor',
|
||||
True,
|
||||
'error',
|
||||
'There was a problem with your practice proctoring session',
|
||||
True
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
None,
|
||||
'Would you like to take "Toy Videos" as a proctored exam?',
|
||||
False
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'submitted',
|
||||
'You have submitted this proctored exam for review',
|
||||
True
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'verified',
|
||||
'Your proctoring session was reviewed and passed all requirements',
|
||||
False
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'rejected',
|
||||
'Your proctoring session was reviewed and did not pass requirements',
|
||||
True
|
||||
),
|
||||
(
|
||||
'verified',
|
||||
False,
|
||||
'error',
|
||||
'There was a problem with your proctoring session',
|
||||
False
|
||||
),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_render_proctored_exam(self, enrollment_mode, is_practice_exam,
|
||||
attempt_status, expected, with_credit_context):
|
||||
"""
|
||||
Verifies gated content from the student view rendering of a sequence
|
||||
this is labeled as a proctored exam
|
||||
"""
|
||||
|
||||
usage_key = self._setup_test_data(enrollment_mode, is_practice_exam, attempt_status)
|
||||
|
||||
# initialize some credit requirements, if so then specify
|
||||
if with_credit_context:
|
||||
credit_course = CreditCourse(course_key=self.course_key, enabled=True)
|
||||
credit_course.save()
|
||||
set_credit_requirements(
|
||||
self.course_key,
|
||||
[
|
||||
{
|
||||
'namespace': 'reverification',
|
||||
'name': 'reverification-1',
|
||||
'display_name': 'ICRV1',
|
||||
'criteria': {},
|
||||
},
|
||||
{
|
||||
'namespace': 'proctored-exam',
|
||||
'name': 'Exam1',
|
||||
'display_name': 'A Proctored Exam',
|
||||
'criteria': {}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
set_credit_requirement_status(
|
||||
self.request.user.username,
|
||||
self.course_key,
|
||||
'reverification',
|
||||
'ICRV1'
|
||||
)
|
||||
|
||||
module = render.get_module(
|
||||
self.request.user,
|
||||
self.request,
|
||||
usage_key,
|
||||
self.field_data_cache,
|
||||
wrap_xmodule_display=True,
|
||||
)
|
||||
content = module.render(STUDENT_VIEW).content
|
||||
|
||||
self.assertIn(expected, content)
|
||||
|
||||
def _setup_test_data(self, enrollment_mode, is_practice_exam, attempt_status):
|
||||
"""
|
||||
Helper method to consolidate some courseware/proctoring/credit
|
||||
test harness data
|
||||
"""
|
||||
usage_key = self.course_key.make_usage_key('videosequence', 'Toy_Videos')
|
||||
sequence = self.modulestore.get_item(usage_key)
|
||||
|
||||
sequence.is_time_limited = True
|
||||
sequence.is_proctored_enabled = True
|
||||
sequence.is_practice_exam = is_practice_exam
|
||||
|
||||
self.modulestore.update_item(sequence, self.user.id)
|
||||
|
||||
self.toy_course = self.modulestore.get_course(self.course_key)
|
||||
|
||||
# refresh cache after update
|
||||
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
self.course_key, self.request.user, self.toy_course, depth=2
|
||||
)
|
||||
|
||||
set_runtime_service(
|
||||
'credit',
|
||||
MockCreditService(enrollment_mode=enrollment_mode)
|
||||
)
|
||||
|
||||
exam_id = create_exam(
|
||||
course_id=unicode(self.course_key),
|
||||
content_id=unicode(sequence.location),
|
||||
exam_name='foo',
|
||||
time_limit_mins=10,
|
||||
is_proctored=True,
|
||||
is_practice_exam=is_practice_exam
|
||||
)
|
||||
|
||||
if attempt_status:
|
||||
create_exam_attempt(exam_id, self.request.user.id, taking_as_proctored=True)
|
||||
update_attempt_status(exam_id, self.request.user.id, attempt_status)
|
||||
|
||||
return usage_key
|
||||
|
||||
def _find_url_name(self, toc, url_name):
|
||||
"""
|
||||
Helper to return the dict TOC section associated with a Chapter of url_name
|
||||
"""
|
||||
|
||||
for entry in toc:
|
||||
if entry['url_name'] == url_name:
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
def _find_section(self, toc, chapter_url_name, section_url_name):
|
||||
"""
|
||||
Helper to return the dict TOC section associated with a section of url_name
|
||||
"""
|
||||
|
||||
chapter = self._find_url_name(toc, chapter_url_name)
|
||||
if chapter:
|
||||
return self._find_url_name(chapter['sections'], section_url_name)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
@ddt.ddt
|
||||
class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
|
||||
71
lms/djangoapps/instructor/services.py
Normal file
71
lms/djangoapps/instructor/services.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Implementation of "Instructor" service
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from courseware.models import StudentModule
|
||||
from instructor.views.tools import get_student_from_identifier
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
import instructor.enrollment as enrollment
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstructorService(object):
|
||||
"""
|
||||
Instructor service for deleting the students attempt(s) of an exam. This service has been created
|
||||
for the edx_proctoring's dependency injection to cater for a requirement where edx_proctoring
|
||||
needs to call into edx-platform's functions to delete the students' existing answers, grades
|
||||
and attempt counts if there had been an earlier attempt.
|
||||
"""
|
||||
|
||||
def delete_student_attempt(self, student_identifier, course_id, content_id):
|
||||
"""
|
||||
Deletes student state for a problem.
|
||||
|
||||
Takes some of the following query parameters
|
||||
- student_identifier is an email or username
|
||||
- content_id is a url-name of a problem
|
||||
- course_id is the id for the course
|
||||
"""
|
||||
course_id = CourseKey.from_string(course_id)
|
||||
|
||||
try:
|
||||
student = get_student_from_identifier(student_identifier)
|
||||
except ObjectDoesNotExist:
|
||||
err_msg = (
|
||||
'Error occurred while attempting to reset student attempts for user '
|
||||
'{student_identifier} for content_id {content_id}. '
|
||||
'User does not exist!'.format(
|
||||
student_identifier=student_identifier,
|
||||
content_id=content_id
|
||||
)
|
||||
)
|
||||
log.error(err_msg)
|
||||
return
|
||||
|
||||
try:
|
||||
module_state_key = UsageKey.from_string(content_id)
|
||||
except InvalidKeyError:
|
||||
err_msg = (
|
||||
'Invalid content_id {content_id}!'.format(content_id=content_id)
|
||||
)
|
||||
log.error(err_msg)
|
||||
return
|
||||
|
||||
if student:
|
||||
try:
|
||||
enrollment.reset_student_attempts(course_id, student, module_state_key, delete_module=True)
|
||||
except (StudentModule.DoesNotExist, enrollment.sub_api.SubmissionError):
|
||||
err_msg = (
|
||||
'Error occurred while attempting to reset student attempts for user '
|
||||
'{student_identifier} for content_id {content_id}.'.format(
|
||||
student_identifier=student_identifier,
|
||||
content_id=content_id
|
||||
)
|
||||
)
|
||||
log.error(err_msg)
|
||||
@@ -101,6 +101,12 @@ REPORTS_DATA = (
|
||||
'instructor_api_endpoint': 'get_students_who_may_enroll',
|
||||
'task_api_endpoint': 'instructor_task.api.submit_calculate_may_enroll_csv',
|
||||
'extra_instructor_api_kwargs': {},
|
||||
},
|
||||
{
|
||||
'report_type': 'proctored exam results',
|
||||
'instructor_api_endpoint': 'get_proctored_exam_results',
|
||||
'task_api_endpoint': 'instructor_task.api.submit_proctored_exam_results_report',
|
||||
'extra_instructor_api_kwargs': {},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -223,6 +229,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
('get_enrollment_report', {}),
|
||||
('get_students_who_may_enroll', {}),
|
||||
('get_exec_summary_report', {}),
|
||||
('get_proctored_exam_results', {}),
|
||||
]
|
||||
# Endpoints that only Instructors can access
|
||||
self.instructor_level_endpoints = [
|
||||
@@ -2316,6 +2323,29 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
|
||||
self.assertIn('status', res_json)
|
||||
self.assertIn('currently being created', res_json['status'])
|
||||
|
||||
def test_get_student_exam_results(self):
|
||||
"""
|
||||
Test whether get_proctored_exam_results returns an appropriate
|
||||
status message when users request a CSV file.
|
||||
"""
|
||||
url = reverse(
|
||||
'get_proctored_exam_results',
|
||||
kwargs={'course_id': unicode(self.course.id)}
|
||||
)
|
||||
# Successful case:
|
||||
response = self.client.get(url, {})
|
||||
res_json = json.loads(response.content)
|
||||
self.assertIn('status', res_json)
|
||||
self.assertNotIn('currently being created', res_json['status'])
|
||||
# CSV generation already in progress:
|
||||
with patch('instructor_task.api.submit_proctored_exam_results_report') as submit_task_function:
|
||||
error = AlreadyRunningError()
|
||||
submit_task_function.side_effect = error
|
||||
response = self.client.get(url, {})
|
||||
res_json = json.loads(response.content)
|
||||
self.assertIn('status', res_json)
|
||||
self.assertIn('currently being created', res_json['status'])
|
||||
|
||||
def test_access_course_finance_admin_with_invalid_course_key(self):
|
||||
"""
|
||||
Test assert require_course fiance_admin before generating
|
||||
|
||||
44
lms/djangoapps/instructor/tests/test_proctoring.py
Normal file
44
lms/djangoapps/instructor/tests/test_proctoring.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Unit tests for Edx Proctoring feature flag in new instructor dashboard.
|
||||
"""
|
||||
|
||||
from mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from nose.plugins.attrib import attr
|
||||
|
||||
from student.roles import CourseFinanceAdminRole
|
||||
from student.tests.factories import AdminFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_PROCTORED_EXAMS': True})
|
||||
class TestProctoringDashboardViews(ModuleStoreTestCase):
|
||||
"""
|
||||
Check for Proctoring view on the new instructor dashboard
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestProctoringDashboardViews, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
self.course.enable_proctored_exams = True
|
||||
|
||||
# Create instructor account
|
||||
self.instructor = AdminFactory.create()
|
||||
self.client.login(username=self.instructor.username, password="test")
|
||||
self.course = self.update_course(self.course, self.instructor.id)
|
||||
|
||||
# URL for instructor dash
|
||||
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
|
||||
self.proctoring_link = '<a href="" data-section="proctoring">Proctoring</a>'
|
||||
CourseFinanceAdminRole(self.course.id).add_users(self.instructor)
|
||||
|
||||
def test_pass_proctoring_tab_in_instructor_dashboard(self):
|
||||
"""
|
||||
Test Pass Proctoring Tab is in the Instructor Dashboard
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertTrue(self.proctoring_link in response.content)
|
||||
self.assertTrue('Allowance Section' in response.content)
|
||||
116
lms/djangoapps/instructor/tests/test_services.py
Normal file
116
lms/djangoapps/instructor/tests/test_services.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests for the InstructorService
|
||||
"""
|
||||
|
||||
import json
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from courseware.models import StudentModule
|
||||
from instructor.services import InstructorService
|
||||
from instructor.tests.test_tools import msk_from_problem_urlname
|
||||
from nose.plugins.attrib import attr
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
class InstructorServiceTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the InstructorService
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(InstructorServiceTests, self).setUp()
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
self.student = UserFactory()
|
||||
CourseEnrollment.enroll(self.student, self.course.id)
|
||||
|
||||
self.problem_location = msk_from_problem_urlname(
|
||||
self.course.id,
|
||||
'robot-some-problem-urlname'
|
||||
)
|
||||
|
||||
self.other_problem_location = msk_from_problem_urlname(
|
||||
self.course.id,
|
||||
'robot-some-other_problem-urlname'
|
||||
)
|
||||
|
||||
self.problem_urlname = unicode(self.problem_location)
|
||||
self.other_problem_urlname = unicode(self.other_problem_location)
|
||||
|
||||
self.service = InstructorService()
|
||||
self.module_to_reset = StudentModule.objects.create(
|
||||
student=self.student,
|
||||
course_id=self.course.id,
|
||||
module_state_key=self.problem_location,
|
||||
state=json.dumps({'attempts': 2}),
|
||||
)
|
||||
|
||||
def test_reset_student_attempts_delete(self):
|
||||
"""
|
||||
Test delete student state.
|
||||
"""
|
||||
|
||||
# make sure the attempt is there
|
||||
self.assertEqual(
|
||||
StudentModule.objects.filter(
|
||||
student=self.module_to_reset.student,
|
||||
course_id=self.course.id,
|
||||
module_state_key=self.module_to_reset.module_state_key,
|
||||
).count(),
|
||||
1
|
||||
)
|
||||
|
||||
self.service.delete_student_attempt(
|
||||
self.student.username,
|
||||
unicode(self.course.id),
|
||||
self.problem_urlname
|
||||
)
|
||||
|
||||
# make sure the module has been deleted
|
||||
self.assertEqual(
|
||||
StudentModule.objects.filter(
|
||||
student=self.module_to_reset.student,
|
||||
course_id=self.course.id,
|
||||
module_state_key=self.module_to_reset.module_state_key,
|
||||
).count(),
|
||||
0
|
||||
)
|
||||
|
||||
def test_reset_bad_content_id(self):
|
||||
"""
|
||||
Negative test of trying to reset attempts with bad content_id
|
||||
"""
|
||||
|
||||
result = self.service.delete_student_attempt(
|
||||
self.student.username,
|
||||
unicode(self.course.id),
|
||||
'foo/bar/baz'
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_reset_bad_user(self):
|
||||
"""
|
||||
Negative test of trying to reset attempts with bad user identifier
|
||||
"""
|
||||
|
||||
result = self.service.delete_student_attempt(
|
||||
'bad_student',
|
||||
unicode(self.course.id),
|
||||
'foo/bar/baz'
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_reset_non_existing_attempt(self):
|
||||
"""
|
||||
Negative test of trying to reset attempts with bad user identifier
|
||||
"""
|
||||
|
||||
result = self.service.delete_student_attempt(
|
||||
self.student.username,
|
||||
unicode(self.course.id),
|
||||
self.other_problem_urlname
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
@@ -1270,6 +1270,43 @@ def get_exec_summary_report(request, course_id):
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
def get_proctored_exam_results(request, course_id):
|
||||
"""
|
||||
get the proctored exam resultsreport for the particular course.
|
||||
"""
|
||||
query_features = [
|
||||
'created',
|
||||
'modified',
|
||||
'started_at',
|
||||
'exam_name',
|
||||
'user_email',
|
||||
'completed_at',
|
||||
'external_id',
|
||||
'allowed_time_limit_mins',
|
||||
'status',
|
||||
'attempt_code',
|
||||
'is_sample_attempt',
|
||||
]
|
||||
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
try:
|
||||
instructor_task.api.submit_proctored_exam_results_report(request, course_key, query_features)
|
||||
status_response = _("The proctored exam results report is being created."
|
||||
" To view the status of the report, see Pending Instructor Tasks below.")
|
||||
except AlreadyRunningError:
|
||||
status_response = _(
|
||||
"The proctored exam results report is currently being created."
|
||||
" To view the status of the report, see Pending Instructor Tasks below."
|
||||
" You will be able to download the report when it is complete."
|
||||
)
|
||||
return JsonResponse({
|
||||
"status": status_response
|
||||
})
|
||||
|
||||
|
||||
def save_registration_code(user, course_id, mode_slug, invoice=None, order=None, invoice_item=None):
|
||||
"""
|
||||
recursive function that generate a new code every time and saves in the Course Registration Table
|
||||
|
||||
@@ -80,6 +80,10 @@ urlpatterns = patterns(
|
||||
url(r'^show_student_extensions$', 'instructor.views.api.show_student_extensions',
|
||||
name='show_student_extensions'),
|
||||
|
||||
# proctored exam downloads...
|
||||
url(r'^get_proctored_exam_results$',
|
||||
'instructor.views.api.get_proctored_exam_results', name="get_proctored_exam_results"),
|
||||
|
||||
# Grade downloads...
|
||||
url(r'^list_report_downloads$',
|
||||
'instructor.views.api.list_report_downloads', name="list_report_downloads"),
|
||||
|
||||
@@ -141,6 +141,10 @@ 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
|
||||
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and course.enable_proctored_exams:
|
||||
sections.append(_section_proctoring(course, access))
|
||||
|
||||
# Certificates panel
|
||||
# This is used to generate example certificates
|
||||
# and enable self-generated certificates for a course.
|
||||
@@ -222,6 +226,19 @@ def _section_e_commerce(course, access, paid_mode, coupons_enabled, reports_enab
|
||||
return section_data
|
||||
|
||||
|
||||
def _section_proctoring(course, access):
|
||||
""" Provide data for the corresponding dashboard section """
|
||||
course_key = course.id
|
||||
|
||||
section_data = {
|
||||
'section_key': 'proctoring',
|
||||
'section_display_name': _('Proctoring'),
|
||||
'access': access,
|
||||
'course_id': unicode(course_key)
|
||||
}
|
||||
return section_data
|
||||
|
||||
|
||||
def _section_certificates(course):
|
||||
"""Section information for the certificates panel.
|
||||
|
||||
@@ -467,12 +484,14 @@ def _section_data_download(course, access):
|
||||
'section_key': 'data_download',
|
||||
'section_display_name': _('Data Download'),
|
||||
'access': access,
|
||||
'show_generate_proctored_exam_report_button': settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False),
|
||||
'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': unicode(course_key)}),
|
||||
'get_students_features_url': reverse('get_students_features', kwargs={'course_id': unicode(course_key)}),
|
||||
'get_students_who_may_enroll_url': reverse(
|
||||
'get_students_who_may_enroll', kwargs={'course_id': unicode(course_key)}
|
||||
),
|
||||
'get_anon_ids_url': reverse('get_anon_ids', kwargs={'course_id': unicode(course_key)}),
|
||||
'list_proctored_results_url': reverse('get_proctored_exam_results', kwargs={'course_id': unicode(course_key)}),
|
||||
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': unicode(course_key)}),
|
||||
'list_report_downloads_url': reverse('list_report_downloads', kwargs={'course_id': unicode(course_key)}),
|
||||
'calculate_grades_csv_url': reverse('calculate_grades_csv', kwargs={'course_id': unicode(course_key)}),
|
||||
|
||||
@@ -16,6 +16,7 @@ import xmodule.graders as xmgraders
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from microsite_configuration import microsite
|
||||
from student.models import CourseEnrollmentAllowed
|
||||
from edx_proctoring.api import get_all_exam_attempts
|
||||
|
||||
|
||||
STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email')
|
||||
@@ -243,6 +244,26 @@ def list_may_enroll(course_key, features):
|
||||
return [extract_student(student, features) for student in may_enroll_and_unenrolled]
|
||||
|
||||
|
||||
def get_proctored_exam_results(course_key, features):
|
||||
"""
|
||||
Return info about proctored exam results in a course as a dict.
|
||||
"""
|
||||
def extract_student(exam_attempt, features):
|
||||
"""
|
||||
Build dict containing information about a single student exam_attempt.
|
||||
"""
|
||||
proctored_exam = dict(
|
||||
(feature, exam_attempt.get(feature)) for feature in features if feature in exam_attempt
|
||||
)
|
||||
proctored_exam.update({'exam_name': exam_attempt.get('proctored_exam').get('exam_name')})
|
||||
proctored_exam.update({'user_email': exam_attempt.get('user').get('email')})
|
||||
|
||||
return proctored_exam
|
||||
|
||||
exam_attempts = get_all_exam_attempts(course_key)
|
||||
return [extract_student(exam_attempt, features) for exam_attempt in exam_attempts]
|
||||
|
||||
|
||||
def coupon_codes_features(features, coupons_list, course_id):
|
||||
"""
|
||||
Return list of Coupon Codes as dictionaries.
|
||||
|
||||
@@ -3,6 +3,9 @@ Tests for instructor.basic
|
||||
"""
|
||||
|
||||
import json
|
||||
import datetime
|
||||
from django.db.models import Q
|
||||
import pytz
|
||||
from student.models import CourseEnrollment, CourseEnrollmentAllowed
|
||||
from django.core.urlresolvers import reverse
|
||||
from mock import patch
|
||||
@@ -16,16 +19,14 @@ from course_modes.models import CourseMode
|
||||
from instructor_analytics.basic import (
|
||||
sale_record_features, sale_order_record_features, enrolled_students_features,
|
||||
course_registration_features, coupon_codes_features, list_may_enroll,
|
||||
AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
|
||||
)
|
||||
AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES,
|
||||
get_proctored_exam_results)
|
||||
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
|
||||
from courseware.tests.factories import InstructorFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
import datetime
|
||||
from django.db.models import Q
|
||||
import pytz
|
||||
from edx_proctoring.api import create_exam
|
||||
from edx_proctoring.models import ProctoredExamStudentAttempt
|
||||
|
||||
|
||||
class TestAnalyticsBasic(ModuleStoreTestCase):
|
||||
@@ -127,6 +128,40 @@ class TestAnalyticsBasic(ModuleStoreTestCase):
|
||||
self.assertEqual(student.keys(), ['email'])
|
||||
self.assertIn(student['email'], email_adresses)
|
||||
|
||||
def test_get_student_exam_attempt_features(self):
|
||||
query_features = [
|
||||
'created',
|
||||
'modified',
|
||||
'started_at',
|
||||
'exam_name',
|
||||
'user_email',
|
||||
'completed_at',
|
||||
'external_id',
|
||||
'allowed_time_limit_mins',
|
||||
'status',
|
||||
'attempt_code',
|
||||
'is_sample_attempt',
|
||||
]
|
||||
|
||||
proctored_exam_id = create_exam(self.course_key, 'Test Content', 'Test Exam', 1)
|
||||
ProctoredExamStudentAttempt.create_exam_attempt(
|
||||
proctored_exam_id, self.users[0].id, '', 1,
|
||||
'Test Code 1', True, False, 'ad13'
|
||||
)
|
||||
ProctoredExamStudentAttempt.create_exam_attempt(
|
||||
proctored_exam_id, self.users[1].id, '', 2,
|
||||
'Test Code 2', True, False, 'ad13'
|
||||
)
|
||||
ProctoredExamStudentAttempt.create_exam_attempt(
|
||||
proctored_exam_id, self.users[2].id, '', 3,
|
||||
'Test Code 3', True, False, 'asd'
|
||||
)
|
||||
|
||||
proctored_exam_attempts = get_proctored_exam_results(self.course_key, query_features)
|
||||
self.assertEqual(len(proctored_exam_attempts), 3)
|
||||
for proctored_exam_attempt in proctored_exam_attempts:
|
||||
self.assertEqual(set(proctored_exam_attempt.keys()), set(query_features))
|
||||
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
|
||||
class TestCourseSaleRecordsAnalyticsBasic(ModuleStoreTestCase):
|
||||
|
||||
@@ -26,6 +26,7 @@ from instructor_task.tasks import (
|
||||
calculate_may_enroll_csv,
|
||||
exec_summary_report_csv,
|
||||
generate_certificates,
|
||||
proctored_exam_results_csv
|
||||
)
|
||||
|
||||
from instructor_task.api_helper import (
|
||||
@@ -408,6 +409,20 @@ def submit_executive_summary_report(request, course_key): # pylint: disable=inv
|
||||
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_proctored_exam_results_report(request, course_key, features): # pylint: disable=invalid-name
|
||||
"""
|
||||
Submits a task to generate a HTML File containing the executive summary report.
|
||||
|
||||
Raises AlreadyRunningError if HTML File is already being updated.
|
||||
"""
|
||||
task_type = 'proctored_exam_results_report'
|
||||
task_class = proctored_exam_results_csv
|
||||
task_input = {'features': features}
|
||||
task_key = ""
|
||||
|
||||
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_cohort_students(request, course_key, file_name):
|
||||
"""
|
||||
Request to have students cohorted in bulk.
|
||||
|
||||
@@ -42,6 +42,7 @@ from instructor_task.tasks_helper import (
|
||||
upload_may_enroll_csv,
|
||||
upload_exec_summary_report,
|
||||
generate_students_certificates,
|
||||
upload_proctored_exam_results_report
|
||||
)
|
||||
|
||||
|
||||
@@ -213,6 +214,17 @@ def exec_summary_report_csv(entry_id, xmodule_instance_args):
|
||||
return run_main_task(entry_id, task_fn, action_name)
|
||||
|
||||
|
||||
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable
|
||||
def proctored_exam_results_csv(entry_id, xmodule_instance_args):
|
||||
"""
|
||||
Compute proctored exam results report for a course and upload the
|
||||
CSV for download.
|
||||
"""
|
||||
action_name = 'generating_proctored_exam_results_report'
|
||||
task_fn = partial(upload_proctored_exam_results_report, xmodule_instance_args)
|
||||
return run_main_task(entry_id, task_fn, action_name)
|
||||
|
||||
|
||||
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable
|
||||
def calculate_may_enroll_csv(entry_id, xmodule_instance_args):
|
||||
"""
|
||||
|
||||
@@ -46,7 +46,7 @@ from courseware.grades import iterate_grades_for
|
||||
from courseware.models import StudentModule
|
||||
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
|
||||
from courseware.module_render import get_module_for_descriptor_internal
|
||||
from instructor_analytics.basic import enrolled_students_features, list_may_enroll
|
||||
from instructor_analytics.basic import enrolled_students_features, list_may_enroll, get_proctored_exam_results
|
||||
from instructor_analytics.csvs import format_dictlist
|
||||
from instructor_task.models import ReportStore, InstructorTask, PROGRESS
|
||||
from lms.djangoapps.lms_xblock.runtime import LmsPartitionService
|
||||
@@ -1191,7 +1191,7 @@ def get_executive_report(course_id):
|
||||
}
|
||||
|
||||
|
||||
def upload_exec_summary_report(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=too-many-statements
|
||||
def upload_exec_summary_report(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=invalid-name
|
||||
"""
|
||||
For a given `course_id`, generate a html report containing information,
|
||||
which provides a snapshot of how the course is doing.
|
||||
@@ -1254,6 +1254,37 @@ def upload_exec_summary_report(_xmodule_instance_args, _entry_id, course_id, _ta
|
||||
return task_progress.update_task_state(extra_meta=current_step)
|
||||
|
||||
|
||||
def upload_proctored_exam_results_report(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): # pylint: disable=invalid-name
|
||||
"""
|
||||
For a given `course_id`, generate a CSV file containing
|
||||
information about proctored exam results, and store using a `ReportStore`.
|
||||
"""
|
||||
start_time = time()
|
||||
start_date = datetime.now(UTC)
|
||||
num_reports = 1
|
||||
task_progress = TaskProgress(action_name, num_reports, start_time)
|
||||
current_step = {'step': 'Calculating info about proctored exam results in a course'}
|
||||
task_progress.update_task_state(extra_meta=current_step)
|
||||
|
||||
# Compute result table and format it
|
||||
query_features = _task_input.get('features')
|
||||
student_data = get_proctored_exam_results(course_id, query_features)
|
||||
header, rows = format_dictlist(student_data, query_features)
|
||||
|
||||
task_progress.attempted = task_progress.succeeded = len(rows)
|
||||
task_progress.skipped = task_progress.total - task_progress.attempted
|
||||
|
||||
rows.insert(0, header)
|
||||
|
||||
current_step = {'step': 'Uploading CSV'}
|
||||
task_progress.update_task_state(extra_meta=current_step)
|
||||
|
||||
# Perform the upload
|
||||
upload_csv_to_report_store(rows, 'proctored_exam_results_report', course_id, start_date)
|
||||
|
||||
return task_progress.update_task_state(extra_meta=current_step)
|
||||
|
||||
|
||||
def generate_students_certificates(
|
||||
_xmodule_instance_args, _entry_id, course_id, task_input, action_name): # pylint: disable=unused-argument
|
||||
"""
|
||||
|
||||
@@ -122,6 +122,8 @@ FEATURES['ENABLE_PAYMENT_FAKE'] = True
|
||||
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
|
||||
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False
|
||||
|
||||
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
|
||||
|
||||
# Don't actually send any requests to Software Secure for student identity
|
||||
# verification.
|
||||
FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True
|
||||
@@ -135,7 +137,7 @@ FEATURES['ENABLE_FEEDBACK_SUBMISSION'] = False
|
||||
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
INSTALLED_APPS += ('lettuce.django',)
|
||||
LETTUCE_APPS = ('courseware', 'instructor',)
|
||||
LETTUCE_APPS = ('courseware', 'instructor')
|
||||
|
||||
# Lettuce appears to have a bug that causes it to search
|
||||
# `instructor_task` when we specify the `instructor` app.
|
||||
|
||||
@@ -686,7 +686,11 @@ LTI_USER_EMAIL_DOMAIN = ENV_TOKENS.get('LTI_USER_EMAIL_DOMAIN', 'lti.example.com
|
||||
##################### Credit Provider help link ####################
|
||||
CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_URL)
|
||||
|
||||
|
||||
#### JWT configuration ####
|
||||
JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER)
|
||||
JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION)
|
||||
|
||||
################# PROCTORING CONFIGURATION ##################
|
||||
|
||||
PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER)
|
||||
PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS)
|
||||
|
||||
@@ -89,7 +89,8 @@
|
||||
"AUTOMATIC_AUTH_FOR_TESTING": true,
|
||||
"MODE_CREATION_FOR_TESTING": true,
|
||||
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true,
|
||||
"ENABLE_COURSE_DISCOVERY": true
|
||||
"ENABLE_COURSE_DISCOVERY": true,
|
||||
"ENABLE_PROCTORED_EXAMS": true
|
||||
},
|
||||
"FEEDBACK_SUBMISSION_EMAIL": "",
|
||||
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
||||
|
||||
@@ -129,6 +129,8 @@ FEATURES['LICENSING'] = True
|
||||
FEATURES['MILESTONES_APP'] = True
|
||||
FEATURES['ENTRANCE_EXAMS'] = True
|
||||
|
||||
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
|
||||
|
||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||
YOUTUBE_PORT = 9080
|
||||
YOUTUBE['API'] = "http://127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
|
||||
|
||||
@@ -407,6 +407,9 @@ 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,
|
||||
|
||||
# Enable OpenBadge support. See the BADGR_* settings later in this file.
|
||||
'ENABLE_OPENBADGES': False,
|
||||
|
||||
@@ -1126,7 +1129,7 @@ TEMPLATE_LOADERS = (
|
||||
'edxmako.makoloader.MakoAppDirectoriesLoader',
|
||||
|
||||
# 'django.template.loaders.filesystem.Loader',
|
||||
# 'django.template.loaders.app_directories.Loader',
|
||||
'django.template.loaders.app_directories.Loader',
|
||||
|
||||
)
|
||||
|
||||
@@ -1221,6 +1224,12 @@ courseware_js = (
|
||||
sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
|
||||
)
|
||||
|
||||
proctoring_js = (
|
||||
['proctoring/js/models/*.js'] +
|
||||
['proctoring/js/collections/*.js'] +
|
||||
['proctoring/js/views/*.js'] +
|
||||
['proctoring/js/*.js']
|
||||
)
|
||||
|
||||
# Before a student accesses courseware, we do not
|
||||
# need many of the JS dependencies. This includes
|
||||
@@ -1511,6 +1520,10 @@ PIPELINE_JS = {
|
||||
],
|
||||
'output_filename': 'js/lms-application.js',
|
||||
},
|
||||
'proctoring': {
|
||||
'source_filenames': proctoring_js,
|
||||
'output_filename': 'js/lms-proctoring.js',
|
||||
},
|
||||
'courseware': {
|
||||
'source_filenames': courseware_js,
|
||||
'output_filename': 'js/lms-courseware.js',
|
||||
@@ -2606,3 +2619,11 @@ JWT_ISSUER = None
|
||||
# Credit notifications settings
|
||||
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
|
||||
NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png"
|
||||
|
||||
#### PROCTORING CONFIGURATION DEFAULTS
|
||||
|
||||
PROCTORING_BACKEND_PROVIDER = {
|
||||
'class': 'edx_proctoring.backends.null.NullBackendProvider',
|
||||
'options': {},
|
||||
}
|
||||
PROCTORING_SETTINGS = {}
|
||||
|
||||
@@ -9,12 +9,16 @@ from django.conf import settings
|
||||
# Force settings to run so that the python path is modified
|
||||
settings.INSTALLED_APPS # pylint: disable=pointless-statement
|
||||
|
||||
from instructor.services import InstructorService
|
||||
|
||||
from openedx.core.lib.django_startup import autostartup
|
||||
import edxmako
|
||||
import logging
|
||||
from monkey_patch import django_utils_translation
|
||||
import analytics
|
||||
|
||||
from edx_proctoring.runtime import set_runtime_service
|
||||
from openedx.core.djangoapps.credit.services import CreditService
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +47,13 @@ def run():
|
||||
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
|
||||
analytics.init(settings.SEGMENT_IO_LMS_KEY, flush_at=50)
|
||||
|
||||
# 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'):
|
||||
set_runtime_service('credit', CreditService())
|
||||
set_runtime_service('instructor', InstructorService())
|
||||
|
||||
|
||||
def add_mimetypes():
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,7 @@ class DataDownload
|
||||
# gather elements
|
||||
@$list_studs_btn = @$section.find("input[name='list-profiles']'")
|
||||
@$list_studs_csv_btn = @$section.find("input[name='list-profiles-csv']'")
|
||||
@$list_proctored_exam_results_csv_btn = @$section.find("input[name='proctored-exam-results-report']'")
|
||||
@$list_may_enroll_csv_btn = @$section.find("input[name='list-may-enroll-csv']")
|
||||
@$list_anon_btn = @$section.find("input[name='list-anon-ids']'")
|
||||
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
|
||||
@@ -45,6 +46,25 @@ class DataDownload
|
||||
url = @$list_anon_btn.data 'endpoint'
|
||||
location.href = url
|
||||
|
||||
# attach click handlers
|
||||
# The list_proctored_exam_results case is always CSV
|
||||
@$list_proctored_exam_results_csv_btn.click (e) =>
|
||||
url = @$list_proctored_exam_results_csv_btn.data 'endpoint'
|
||||
# display html from proctored exam results config endpoint
|
||||
$.ajax
|
||||
dataType: 'json'
|
||||
url: url
|
||||
error: (std_ajax_err) =>
|
||||
@clear_display()
|
||||
@$reports_request_response_error.text gettext(
|
||||
"Error generating proctored exam results. Please try again."
|
||||
)
|
||||
$(".msg-error").css({"display":"block"})
|
||||
success: (data) =>
|
||||
@clear_display()
|
||||
@$reports_request_response.text data['status']
|
||||
$(".msg-confirm").css({"display":"block"})
|
||||
|
||||
# this handler binds to both the download
|
||||
# and the csv button
|
||||
@$list_studs_csv_btn.click (e) =>
|
||||
|
||||
@@ -184,6 +184,16 @@ setup_instructor_dashboard_sections = (idash_content) ->
|
||||
$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"
|
||||
,
|
||||
constructor: edx.instructor_dashboard.proctoring.ProctoredExamAttemptView
|
||||
$element: idash_content.find ".#{CSS_IDASH_SECTION}#proctoring"
|
||||
]
|
||||
|
||||
sections_to_initialize.map ({constructor, $element}) ->
|
||||
# See fault isolation NOTE at top of file.
|
||||
# If an error is thrown in one section, it will not stop other sections from exectuing.
|
||||
|
||||
22
lms/static/js/instructor_dashboard/proctoring.js
Normal file
22
lms/static/js/instructor_dashboard/proctoring.js
Normal file
@@ -0,0 +1,22 @@
|
||||
$(function() {
|
||||
var icons = {
|
||||
header: "ui-icon-carat-1-e",
|
||||
activeHeader: "ui-icon-carat-1-s"
|
||||
};
|
||||
var proctoringAccordionPane = $("#proctoring-accordion");
|
||||
proctoringAccordionPane.accordion(
|
||||
{
|
||||
heightStyle: 'content',
|
||||
activate: function (event, ui) {
|
||||
var active = proctoringAccordionPane.accordion('option', 'active');
|
||||
$.cookie('saved_index', null);
|
||||
$.cookie('saved_index', active);
|
||||
},
|
||||
animate: 400,
|
||||
header: "> div.wrap >h2",
|
||||
icons: icons,
|
||||
active: isNaN(parseInt($.cookie('saved_index'))) ? 0 : parseInt($.cookie('saved_index')),
|
||||
collapsible: true
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -3,10 +3,10 @@ html {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
html.video-fullscreen{
|
||||
html.video-fullscreen {
|
||||
overflow: hidden;
|
||||
|
||||
body{
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,210 @@ div.course-wrapper {
|
||||
h1 {
|
||||
margin: 0 0 lh();
|
||||
}
|
||||
div.timed-exam {
|
||||
h3 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: ($baseline/2);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h4 {
|
||||
margin: 20px 0;
|
||||
font-weight: 600;
|
||||
b.success {
|
||||
color: #2B8048;
|
||||
}
|
||||
b.success {
|
||||
color: #2B8048;
|
||||
}
|
||||
b.failure {
|
||||
color: #CB4765;
|
||||
}
|
||||
}
|
||||
p {
|
||||
color: #797676;
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
button.gated-sequence {
|
||||
background-color: transparent;
|
||||
border-bottom: none;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
button.gated-sequence > a {
|
||||
color: #147ABA;
|
||||
}
|
||||
span.proctored-exam-code {
|
||||
margin-top: 5px;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.gated-sequence {
|
||||
color: #147ABA;
|
||||
font-weight: 600;
|
||||
a.start-timed-exam {
|
||||
cursor: pointer;
|
||||
color: #147ABA;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
top: ($baseline/10);
|
||||
i.fa-arrow-circle-right {
|
||||
font-size: $baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.proctored-exam-select-code {
|
||||
margin-left: 30px;
|
||||
}
|
||||
background-color: #F2F4F5;
|
||||
padding: 30px;
|
||||
font-size: 16px;
|
||||
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, .1);
|
||||
border: 1px solid #ddd;
|
||||
&.critical-time {
|
||||
border-left: 4px solid #b30101 !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
&.success {
|
||||
border-left: 4px solid #22B557 !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
&.success-top-bar {
|
||||
border-top: 4px solid #22B557 !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
&.message-top-bar {
|
||||
border-top: 4px solid #FAB95C !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
&.failure {
|
||||
border-left: 4px solid #C93B34 !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
div.proctored-exam {
|
||||
@extend .timed-exam;
|
||||
.proctored-exam-message {
|
||||
border-top: ($baseline/10) solid rgb(207, 216, 220);
|
||||
padding-top: 25px;
|
||||
}
|
||||
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);
|
||||
}
|
||||
.gated-sequence {
|
||||
border-bottom: 2px solid rgb(207, 216, 220);
|
||||
padding: 15px ($baseline*5) 15px 50px;
|
||||
position: relative;
|
||||
span {
|
||||
.fa {
|
||||
position: absolute;
|
||||
font-size: 22px;
|
||||
left: 0;
|
||||
top: $baseline;
|
||||
color: rgb(206, 216, 220)
|
||||
|
||||
}
|
||||
}
|
||||
.start-timed-exam {
|
||||
margin-bottom:($baseline/2);
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
color: rgb(63, 58, 59);
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
> .fa {
|
||||
position: absolute;
|
||||
right: 35px;
|
||||
top: 50%;
|
||||
font-size: 30px;
|
||||
margin-top: -15px;
|
||||
}
|
||||
&:last-child {
|
||||
> .fa {
|
||||
color: rgb(206, 216, 220);
|
||||
}
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.footer-sequence {
|
||||
padding: 30px 0px 20px 0px;
|
||||
border-bottom: ($baseline/10) solid #CFD9DD;
|
||||
hr {
|
||||
border-bottom: 1px solid rgb(207, 216, 220);
|
||||
}
|
||||
.clearfix {
|
||||
clear: both;
|
||||
}
|
||||
h4 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
span {
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
}
|
||||
p.proctored-exam-option {
|
||||
float: left;
|
||||
width: 80%;
|
||||
margin-bottom: 25px;
|
||||
|
||||
}
|
||||
a.contest-review {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
}
|
||||
p {
|
||||
margin-bottom: ($baseline/20);
|
||||
color: #797676;
|
||||
}
|
||||
.proctored-exam-instruction{
|
||||
padding: ($baseline/2) 0;
|
||||
border-bottom: 2px solid rgb(207, 216, 220);
|
||||
}
|
||||
}
|
||||
.border-b-0 {
|
||||
border-bottom: none;
|
||||
}
|
||||
.padding-b-0 {
|
||||
padding-bottom: ($baseline/20);
|
||||
}
|
||||
.faq-proctoring-exam {
|
||||
@extend .footer-sequence;
|
||||
border-bottom : none;
|
||||
a.footer-link {
|
||||
display: block;
|
||||
padding: 10px 0px 10px 0px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin-bottom: lh();
|
||||
}
|
||||
@@ -197,8 +400,8 @@ div.course-wrapper {
|
||||
|
||||
nav.sequence-bottom {
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +463,7 @@ div.course-wrapper {
|
||||
}
|
||||
p.success {
|
||||
color: $success-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.staff_info {
|
||||
@@ -318,14 +521,14 @@ div.course-wrapper {
|
||||
width: 10px;
|
||||
padding: 0;
|
||||
|
||||
nav {
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
nav {
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
|
||||
ul {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
ul {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,46 +547,45 @@ div.course-wrapper {
|
||||
.xmodule_VideoModule {
|
||||
margin-bottom: ($baseline*1.5);
|
||||
|
||||
|
||||
}
|
||||
|
||||
textarea.short-form-response {
|
||||
height: 200px;
|
||||
padding: ($baseline/4);
|
||||
margin-top: ($baseline/4);
|
||||
margin-bottom: ($baseline/4);
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
padding: ($baseline/4);
|
||||
margin-top: ($baseline/4);
|
||||
margin-bottom: ($baseline/4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.self-assessment {
|
||||
textarea.hint {
|
||||
height: 100px;
|
||||
padding: ($baseline/4);
|
||||
margin-top: ($baseline/4);
|
||||
margin-bottom: ($baseline/4);
|
||||
}
|
||||
textarea.hint {
|
||||
height: 100px;
|
||||
padding: ($baseline/4);
|
||||
margin-top: ($baseline/4);
|
||||
margin-bottom: ($baseline/4);
|
||||
}
|
||||
|
||||
div {
|
||||
margin-top: ($baseline/4);
|
||||
margin-bottom: ($baseline/4);
|
||||
}
|
||||
div {
|
||||
margin-top: ($baseline/4);
|
||||
margin-bottom: ($baseline/4);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.error {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
section.foldit {
|
||||
table {
|
||||
margin-top: ($baseline/2);
|
||||
}
|
||||
th {
|
||||
text-align: center;
|
||||
}
|
||||
td {
|
||||
padding-left: ($baseline/4);
|
||||
padding-right: ($baseline/4);
|
||||
table {
|
||||
margin-top: ($baseline/2);
|
||||
}
|
||||
th {
|
||||
text-align: center;
|
||||
}
|
||||
td {
|
||||
padding-left: ($baseline/4);
|
||||
padding-right: ($baseline/4);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,19 @@
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// definitions for proctored exam attempt status indicators
|
||||
i.verified {
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
i.rejected {
|
||||
color: $alert-color;
|
||||
}
|
||||
|
||||
i.error {
|
||||
color: $alert-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1841,7 +1841,7 @@ input[name="subject"] {
|
||||
}
|
||||
}
|
||||
|
||||
.ecommerce-wrapper{
|
||||
.ecommerce-wrapper, .proctoring-wrapper{
|
||||
h2{
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
@@ -1910,6 +1910,190 @@ input[name="subject"] {
|
||||
}
|
||||
}
|
||||
}
|
||||
.special-allowance-container, .student-proctored-exam-container{
|
||||
.allowance-table, .exam-attempts-table {
|
||||
width: 100%;
|
||||
tr:nth-child(even){
|
||||
background-color: $gray-l6;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
}
|
||||
.allowance-headings, .exam-attempt-headings {
|
||||
height: 40px;
|
||||
border-bottom: 1px solid #BEBEBE;
|
||||
|
||||
th:nth-child(5){
|
||||
text-align: center;
|
||||
width: 120px;
|
||||
}
|
||||
th:first-child{
|
||||
padding-left: $baseline;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid $border-color-1;
|
||||
font-size: 16px;
|
||||
&.attempt-allowed-time {
|
||||
width: 140px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
&.attempt-started-at {
|
||||
width: 160px;
|
||||
}
|
||||
&.attempt-completed-at {
|
||||
width: 160px;
|
||||
}
|
||||
&.attempt-status {
|
||||
width: 100px;
|
||||
}
|
||||
&.exam-name {
|
||||
width: 150px;
|
||||
}
|
||||
&.username {
|
||||
width: 140px;
|
||||
}
|
||||
&.email {
|
||||
width: 250px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
&.allowance-name {
|
||||
width: 140px;
|
||||
}
|
||||
&.allowance-value {
|
||||
width: 150px;
|
||||
}
|
||||
&.c_action {
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
// allowance-items style
|
||||
.allowance-items {
|
||||
td {
|
||||
padding: ($baseline/2) 0;
|
||||
position: relative;
|
||||
line-height: normal;
|
||||
font-size: 14px;
|
||||
}
|
||||
td:nth-child(5),td:first-child{
|
||||
@include padding-left($baseline);
|
||||
}
|
||||
td:nth-child(2){
|
||||
line-height: 22px;
|
||||
@include padding-right(0px);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
td:nth-child(5){
|
||||
@include padding-left(0);
|
||||
text-align: center;
|
||||
}
|
||||
td{
|
||||
a.remove_allowance{
|
||||
@include margin-left(15px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.top-header {
|
||||
margin-top: -30px;
|
||||
margin-bottom: 20px;
|
||||
.search-attempts {
|
||||
border: 1px solid #ccc;
|
||||
display: inline-block;
|
||||
border-radius: 5px;
|
||||
float: left;
|
||||
input {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
width: 240px;
|
||||
}
|
||||
span:first-child {
|
||||
margin-right: -5px;
|
||||
}
|
||||
span {
|
||||
background-color: #ccc;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
.pagination {
|
||||
display: inline-block;
|
||||
padding-left: 0;
|
||||
float: right;
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
> li {
|
||||
display: inline; // Remove list-style and block-level defaults
|
||||
> a,
|
||||
> span {
|
||||
padding: 6px 12px;
|
||||
line-height: 1.41;
|
||||
text-decoration: none;
|
||||
color: #00095f;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
margin-left: -1px;
|
||||
}
|
||||
> a.active {
|
||||
background-color: #ccc;
|
||||
}
|
||||
&:first-child {
|
||||
> a,
|
||||
> span {
|
||||
margin-left: 0;
|
||||
@include border-left-radius(4px);
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
> a,
|
||||
> span {
|
||||
@include border-right-radius(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> li > a,
|
||||
> li > span {
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 3;
|
||||
color: darken(#003a7d, 15%);
|
||||
background-color: lighten(#000, 93.5%);
|
||||
border-color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
> .active > a,
|
||||
> .active > span {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
color: #fff;
|
||||
background-color: darken(#428bca, 6.5%);
|
||||
border-color: darken(#428bca, 6.5%);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
> .disabled {
|
||||
> span,
|
||||
> span:hover,
|
||||
> span:focus,
|
||||
> a,
|
||||
> a:hover,
|
||||
> a:focus {
|
||||
color: lighten(#000, 46.7%);
|
||||
background-color: #fff;
|
||||
border-color: #ddd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rtl .instructor-dashboard-wrapper-2 .olddash-button-wrapper,
|
||||
.rtl .instructor-dashboard-wrapper-2 .studio-edit-link {
|
||||
|
||||
@@ -47,3 +47,47 @@
|
||||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
.exam-timer {
|
||||
line-height: 56px;
|
||||
background-color: #e5eaec;
|
||||
padding-left: 42px;
|
||||
padding-right: 32px;
|
||||
border-left: 4px solid #14a6ea;
|
||||
margin: 0 auto;
|
||||
color: #4e575b;
|
||||
font-size: 14px;
|
||||
a {
|
||||
color: #0979ba;
|
||||
}
|
||||
span.pull-right {
|
||||
color: #646161;
|
||||
line-height: 56px;
|
||||
b {
|
||||
color: #414040;
|
||||
}
|
||||
}
|
||||
&.low-time {
|
||||
color: #cdd7db;
|
||||
background-color: #4F585C;
|
||||
a {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
span.pull-right {
|
||||
color: #cdd7db;
|
||||
b {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.warning {
|
||||
border-left-color: #feb93e;
|
||||
}
|
||||
&.critical {
|
||||
border-left-color: #b30101;
|
||||
color: #fff;
|
||||
}
|
||||
.exam-button-turn-in-exam {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,37 @@
|
||||
formatted_string = get_time_display(section['due'], due_date_display_format, coerce_tz=settings.TIME_ZONE_DISPLAYED_FOR_DEADLINES)
|
||||
due_date = '' if len(formatted_string)==0 else _('due {date}').format(date=formatted_string)
|
||||
%>
|
||||
<p class="subtitle">${section['format']} ${due_date}</p>
|
||||
|
||||
## There is behavior differences between
|
||||
## rending of sections which have proctoring/timed examinations
|
||||
## and those that do not.
|
||||
##
|
||||
## Proctoring exposes a exam status message field as well as
|
||||
## a status icon
|
||||
|
||||
% if section['format'] or due_date or 'proctoring' in section:
|
||||
<p class="subtitle">
|
||||
% if 'proctoring' in section:
|
||||
## Display the proctored exam status icon and status message
|
||||
<i class="fa ${section['proctoring'].get('suggested_icon', 'fa-lock')} ${section['proctoring'].get('status', 'eligible')}"></i>
|
||||
<span class="subtitle-name">${section['proctoring'].get('short_description', '')}
|
||||
</span>
|
||||
## completed proctored exam statuses should not show the due date
|
||||
## since the exam has already been submitted by the user
|
||||
% if not section['proctoring'].get('in_completed_state', False):
|
||||
<span class="subtitle-name">${due_date}</span>
|
||||
% endif
|
||||
% else:
|
||||
## non-proctored section, we just show the exam format and the due date
|
||||
## this is the standard case in edx-platform
|
||||
<span class="subtitle-name">${section['format']} ${due_date}
|
||||
</span>
|
||||
% endif
|
||||
</p>
|
||||
% endif
|
||||
|
||||
% if 'graded' in section and section['graded']:
|
||||
## sections that are graded should indicate this through an icon
|
||||
<i class="icon fa fa-pencil-square-o" aria-hidden="true" data-tooltip="${_("This section is graded.")}"></i>
|
||||
% endif
|
||||
</a>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
## mako
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from courseware.tabs import get_course_tab_list
|
||||
from courseware.views import notification_image_for_tab
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
|
||||
from student.models import CourseEnrollment
|
||||
%>
|
||||
@@ -29,8 +31,18 @@ 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
|
||||
%>
|
||||
<%static:js group='proctoring'/>
|
||||
|
||||
% if include_proctoring:
|
||||
% for template_name in ["proctored-exam-status"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="courseware/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
<div class="proctored_exam_status"></div>
|
||||
% endif
|
||||
% if show_preview_menu:
|
||||
<nav class="wrapper-preview-menu" aria-label="${_('Course View')}">
|
||||
<div class="preview-menu">
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.template.defaultfilters import escapejs
|
||||
from microsite_configuration import page_title_breadcrumbs
|
||||
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
|
||||
%>
|
||||
<%def name="course_name()">
|
||||
<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
|
||||
</%def>
|
||||
@@ -25,13 +29,23 @@ ${page_title_breadcrumbs(course_name())}
|
||||
<%static:include path="common/templates/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
|
||||
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
|
||||
% for template_name in ["course_search_item", "course_search_results", "search_loading", "search_error"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="search/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="search/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
% endif
|
||||
|
||||
% if include_proctoring:
|
||||
% for template_name in ["proctored-exam-status"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="courseware/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
% endif
|
||||
|
||||
</%block>
|
||||
|
||||
<%block name="headextra">
|
||||
@@ -81,7 +95,7 @@ ${page_title_breadcrumbs(course_name())}
|
||||
var $$course_id = "${course.id | escapejs}";
|
||||
|
||||
$(function(){
|
||||
$(".ui-accordion-header a, .ui-accordion-content .subtitle").each(function() {
|
||||
$(".ui-accordion-header a, .ui-accordion-content .subtitle-name").each(function() {
|
||||
var elemText = $(this).text().replace(/^\s+|\s+$/g,''); // Strip leading and trailing whitespace
|
||||
var wordArray = elemText.split(" ");
|
||||
var finalTitle = "";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="wrap-instructor-info studio-view">
|
||||
<a class="instructor-info-action proctored-exam-action proctored-exam-action-stop">
|
||||
<%- gettext("Mark Exam As Completed") %>
|
||||
</a>
|
||||
</div>
|
||||
24
lms/templates/courseware/proctored-exam-status.underscore
Normal file
24
lms/templates/courseware/proctored-exam-status.underscore
Normal file
@@ -0,0 +1,24 @@
|
||||
<div class="exam-timer">
|
||||
<%
|
||||
function gtLtEscape(str) {
|
||||
return String(str)
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
%>
|
||||
<%= interpolate_text('You are taking "{exam_link}" as a proctored 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>"}) %>
|
||||
<span id="turn_in_exam_id" class="pull-right">
|
||||
<span id="turn_in_exam_id">
|
||||
<% if(attempt_status !== 'ready_to_submit') {%>
|
||||
<button class="exam-button-turn-in-exam">
|
||||
<%- gettext("End My Exam") %>
|
||||
</button>
|
||||
<% } %>
|
||||
</span>
|
||||
<span id="time_remaining_id">
|
||||
<b>
|
||||
</b>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
|
||||
<p><input type="button" name="list-may-enroll-csv" value="${_("Download a CSV of learners who can enroll")}" data-endpoint="${ section_data['get_students_who_may_enroll_url'] }" data-csv="true"></p>
|
||||
|
||||
%if section_data['show_generate_proctored_exam_report_button']:
|
||||
<p>${_("Click to generate a CSV file of all proctored exam results in this course.")}</p>
|
||||
<p><input type="button" name="proctored-exam-results-report" value="${_("Generate Proctored Exam Results Report")}" data-endpoint="${ section_data['list_proctored_results_url'] }"/></p>
|
||||
%endif
|
||||
% if not disable_buttons:
|
||||
<p>${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}</p>
|
||||
<p><input type="button" name="list-profiles" value="${_("List enrolled students' profile information")}" data-endpoint="${ section_data['get_students_features_url'] }"></p>
|
||||
|
||||
@@ -34,6 +34,8 @@ from django.core.urlresolvers import reverse
|
||||
window.Range.prototype = { };
|
||||
}
|
||||
</script>
|
||||
<script type="text/javascript" src="${static.url('js/instructor_dashboard/proctoring.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/date.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/mustache.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.js')}"></script>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
%>
|
||||
<%page args="section_data"/>
|
||||
<div class="proctoring-wrapper">
|
||||
<div id = "proctoring-accordion">
|
||||
<div class="wrap">
|
||||
<h2>${_('Allowance Section')}</h2>
|
||||
<div class="special-allowance-container" data-course-id="${ section_data['course_id'] }"></div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<h2>${_('Student Proctored Exam Section')}</h2>
|
||||
<div class="student-proctored-exam-container" data-course-id="${ section_data['course_id'] }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,8 +48,6 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
var sequenceNav;
|
||||
$(document).ready(function() {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{% block bodyclass %}view-incourse view-wiki{% endblock %}
|
||||
|
||||
{% block headextra %}
|
||||
<script type="text/javascript" src="/i18n.js"></script>
|
||||
{% compressed_css 'course' %}
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -711,3 +711,8 @@ urlpatterns += (
|
||||
url(r'^404$', handler404),
|
||||
url(r'^500$', handler500),
|
||||
)
|
||||
|
||||
# include into our URL patterns the HTTP REST API that comes with edx-proctoring.
|
||||
urlpatterns += (
|
||||
url(r'^api/', include('edx_proctoring.urls')),
|
||||
)
|
||||
|
||||
132
openedx/core/djangoapps/credit/services.py
Normal file
132
openedx/core/djangoapps/credit/services.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Implementation of "credit" XBlock service
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_course_key(course_key_or_id):
|
||||
"""
|
||||
Helper method to get a course key eith from a string or a CourseKey,
|
||||
where the CourseKey will simply be returned
|
||||
"""
|
||||
return (
|
||||
CourseKey.from_string(course_key_or_id)
|
||||
if isinstance(course_key_or_id, basestring)
|
||||
else course_key_or_id
|
||||
)
|
||||
|
||||
|
||||
class CreditService(object):
|
||||
"""
|
||||
Course Credit XBlock service
|
||||
"""
|
||||
|
||||
def get_credit_state(self, user_id, course_key_or_id):
|
||||
"""
|
||||
Return all information about the user's credit state inside of a given
|
||||
course.
|
||||
|
||||
ARGS:
|
||||
- user_id: The PK of the User in question
|
||||
- course_key: The course ID (as string or CourseKey)
|
||||
|
||||
RETURNS:
|
||||
NONE (user not found or is not enrolled or is not credit course)
|
||||
- or -
|
||||
{
|
||||
'enrollment_mode': the mode that the user is enrolled in the course
|
||||
'profile_fullname': the name that the student registered under, used for verification
|
||||
'credit_requirement_status': the user's status in fulfilling those requirements
|
||||
}
|
||||
"""
|
||||
|
||||
# This seems to need to be here otherwise we get
|
||||
# circular references when starting up the app
|
||||
from openedx.core.djangoapps.credit.api.eligibility import (
|
||||
is_credit_course,
|
||||
get_credit_requirement_status,
|
||||
)
|
||||
|
||||
# since we have to do name matching during various
|
||||
# verifications, User must have a UserProfile
|
||||
try:
|
||||
user = User.objects.select_related('profile').get(id=user_id)
|
||||
except ObjectDoesNotExist:
|
||||
# bad user_id
|
||||
return None
|
||||
|
||||
course_key = _get_course_key(course_key_or_id)
|
||||
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course_key)
|
||||
if not enrollment or not enrollment.is_active:
|
||||
# not enrolled
|
||||
return None
|
||||
|
||||
if not is_credit_course(course_key):
|
||||
return None
|
||||
|
||||
return {
|
||||
'enrollment_mode': enrollment.mode,
|
||||
'profile_fullname': user.profile.name,
|
||||
'credit_requirement_status': get_credit_requirement_status(course_key, user.username)
|
||||
}
|
||||
|
||||
def set_credit_requirement_status(self, user_id, course_key_or_id, req_namespace,
|
||||
req_name, status="satisfied", reason=None):
|
||||
"""
|
||||
A simple wrapper around the method of the same name in api.eligibility.py. The only difference is
|
||||
that a user_id is passed in.
|
||||
|
||||
For more information, see documentation on this method name in api.eligibility.py
|
||||
"""
|
||||
|
||||
# always log any update activity to the credit requirements
|
||||
# table. This will be to help debug any issues that might
|
||||
# arise in production
|
||||
log_msg = (
|
||||
'set_credit_requirement_status was called with '
|
||||
'user_id={user_id}, course_key_or_id={course_key_or_id} '
|
||||
'req_namespace={req_namespace}, req_name={req_name}, '
|
||||
'status={status}, reason={reason}'.format(
|
||||
user_id=user_id,
|
||||
course_key_or_id=course_key_or_id,
|
||||
req_namespace=req_namespace,
|
||||
req_name=req_name,
|
||||
status=status,
|
||||
reason=reason
|
||||
)
|
||||
)
|
||||
log.info(log_msg)
|
||||
|
||||
# need to get user_name from the user object
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except ObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
course_key = _get_course_key(course_key_or_id)
|
||||
|
||||
# This seems to need to be here otherwise we get
|
||||
# circular references when starting up the app
|
||||
from openedx.core.djangoapps.credit.api.eligibility import (
|
||||
set_credit_requirement_status as api_set_credit_requirement_status
|
||||
)
|
||||
|
||||
api_set_credit_requirement_status(
|
||||
user.username,
|
||||
course_key,
|
||||
req_namespace,
|
||||
req_name,
|
||||
status,
|
||||
reason
|
||||
)
|
||||
@@ -231,12 +231,13 @@ def _get_proctoring_requirements(course_key):
|
||||
requirements = [
|
||||
{
|
||||
'namespace': 'proctored_exam',
|
||||
'name': 'proctored_exam_id:{id}'.format(id=exam['id']),
|
||||
'name': exam['content_id'],
|
||||
'display_name': exam['exam_name'],
|
||||
'criteria': {},
|
||||
}
|
||||
for exam in get_all_exams_for_course(unicode(course_key))
|
||||
if exam['is_proctored'] and exam['is_active']
|
||||
# practice exams do not count towards eligibility
|
||||
if exam['is_proctored'] and exam['is_active'] and not exam['is_practice_exam']
|
||||
]
|
||||
|
||||
log_msg = (
|
||||
|
||||
185
openedx/core/djangoapps/credit/tests/test_services.py
Normal file
185
openedx/core/djangoapps/credit/tests/test_services.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Tests for the Credit xBlock service
|
||||
"""
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from openedx.core.djangoapps.credit.services import CreditService
|
||||
from openedx.core.djangoapps.credit.models import CreditCourse
|
||||
from openedx.core.djangoapps.credit.api.eligibility import set_credit_requirements
|
||||
|
||||
from student.models import CourseEnrollment, UserProfile
|
||||
|
||||
|
||||
class CreditServiceTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the Credit xBlock service
|
||||
"""
|
||||
|
||||
def setUp(self, **kwargs):
|
||||
super(CreditServiceTests, self).setUp()
|
||||
|
||||
self.service = CreditService()
|
||||
self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course')
|
||||
self.credit_course = CreditCourse.objects.create(course_key=self.course.id, enabled=True)
|
||||
self.profile = UserProfile.objects.create(user_id=self.user.id, name='Foo Bar')
|
||||
|
||||
def test_user_not_found(self):
|
||||
"""
|
||||
Makes sure that get_credit_state returns None if user_id cannot be found
|
||||
"""
|
||||
|
||||
self.assertIsNone(self.service.get_credit_state(0, self.course.id))
|
||||
|
||||
def test_user_not_enrolled(self):
|
||||
"""
|
||||
Makes sure that get_credit_state returns None if user_id is not enrolled
|
||||
in the test course
|
||||
"""
|
||||
|
||||
self.assertIsNone(self.service.get_credit_state(self.user.id, self.course.id))
|
||||
|
||||
def test_inactive_enrollment(self):
|
||||
"""
|
||||
Makes sure that get_credit_state returns None if the user's enrollment is
|
||||
inactive
|
||||
"""
|
||||
|
||||
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
|
||||
enrollment.is_active = False
|
||||
enrollment.save()
|
||||
|
||||
self.assertIsNone(self.service.get_credit_state(self.user.id, self.course.id))
|
||||
|
||||
def test_not_credit_course(self):
|
||||
"""
|
||||
Makes sure that get_credit_state returns None if the test course is not
|
||||
Credit eligible
|
||||
"""
|
||||
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
|
||||
self.credit_course.enabled = False
|
||||
self.credit_course.save()
|
||||
|
||||
self.assertIsNone(self.service.get_credit_state(self.user.id, self.course.id))
|
||||
|
||||
def test_no_profile_name(self):
|
||||
"""
|
||||
Makes sure that get_credit_state returns None if the user does not
|
||||
have a corresponding UserProfile. This shouldn't happen in
|
||||
real environments
|
||||
"""
|
||||
|
||||
profile = UserProfile.objects.get(user_id=self.user.id)
|
||||
profile.delete()
|
||||
|
||||
self.assertIsNone(self.service.get_credit_state(self.user.id, self.course.id))
|
||||
|
||||
def test_get_and_set_credit_state(self):
|
||||
"""
|
||||
Happy path through the service
|
||||
"""
|
||||
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
|
||||
# set course requirements
|
||||
set_credit_requirements(
|
||||
self.course.id,
|
||||
[
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {
|
||||
"min_grade": 0.8
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# mark the grade as satisfied
|
||||
self.service.set_credit_requirement_status(
|
||||
self.user.id,
|
||||
self.course.id,
|
||||
'grade',
|
||||
'grade'
|
||||
)
|
||||
|
||||
credit_state = self.service.get_credit_state(self.user.id, self.course.id)
|
||||
|
||||
self.assertIsNotNone(credit_state)
|
||||
self.assertEqual(credit_state['enrollment_mode'], 'honor')
|
||||
self.assertEqual(credit_state['profile_fullname'], 'Foo Bar')
|
||||
self.assertEqual(len(credit_state['credit_requirement_status']), 1)
|
||||
self.assertEqual(credit_state['credit_requirement_status'][0]['name'], 'grade')
|
||||
self.assertEqual(credit_state['credit_requirement_status'][0]['status'], 'satisfied')
|
||||
|
||||
def test_bad_user(self):
|
||||
"""
|
||||
Try setting requirements status with a bad user_id
|
||||
"""
|
||||
|
||||
# set course requirements
|
||||
set_credit_requirements(
|
||||
self.course.id,
|
||||
[
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {
|
||||
"min_grade": 0.8
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# mark the grade as satisfied
|
||||
retval = self.service.set_credit_requirement_status(
|
||||
0,
|
||||
self.course.id,
|
||||
'grade',
|
||||
'grade'
|
||||
)
|
||||
self.assertIsNone(retval)
|
||||
|
||||
def test_course_id_string(self):
|
||||
"""
|
||||
Make sure we can pass a course_id (string) and get back correct results as well
|
||||
"""
|
||||
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
|
||||
# set course requirements
|
||||
set_credit_requirements(
|
||||
self.course.id,
|
||||
[
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {
|
||||
"min_grade": 0.8
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# mark the grade as satisfied
|
||||
self.service.set_credit_requirement_status(
|
||||
self.user.id,
|
||||
unicode(self.course.id),
|
||||
'grade',
|
||||
'grade'
|
||||
)
|
||||
|
||||
credit_state = self.service.get_credit_state(self.user.id, unicode(self.course.id))
|
||||
|
||||
self.assertIsNotNone(credit_state)
|
||||
self.assertEqual(credit_state['enrollment_mode'], 'honor')
|
||||
self.assertEqual(credit_state['profile_fullname'], 'Foo Bar')
|
||||
self.assertEqual(len(credit_state['credit_requirement_status']), 1)
|
||||
self.assertEqual(credit_state['credit_requirement_status'][0]['name'], 'grade')
|
||||
self.assertEqual(credit_state['credit_requirement_status'][0]['status'], 'satisfied')
|
||||
@@ -125,13 +125,14 @@ class TestTaskExecution(ModuleStoreTestCase):
|
||||
|
||||
self.assertEqual(len(requirements), 1)
|
||||
self.assertEqual(requirements[0]['namespace'], 'proctored_exam')
|
||||
self.assertEqual(requirements[0]['name'], 'proctored_exam_id:1')
|
||||
self.assertEqual(requirements[0]['name'], 'foo')
|
||||
self.assertEqual(requirements[0]['display_name'], 'A Proctored Exam')
|
||||
self.assertEqual(requirements[0]['criteria'], {})
|
||||
|
||||
def test_proctored_exam_filtering(self):
|
||||
"""
|
||||
Make sure that timed or inactive exams do not end up in the requirements table
|
||||
Also practice protored exams are not a requirement
|
||||
"""
|
||||
|
||||
self.add_credit_course(self.course.id)
|
||||
@@ -180,6 +181,29 @@ class TestTaskExecution(ModuleStoreTestCase):
|
||||
if requirement['namespace'] == 'proctored_exam'
|
||||
])
|
||||
|
||||
# practice proctored exams aren't requirements
|
||||
create_exam(
|
||||
course_id=unicode(self.course.id),
|
||||
content_id='foo3',
|
||||
exam_name='A Proctored Exam',
|
||||
time_limit_mins=10,
|
||||
is_proctored=True,
|
||||
is_active=True,
|
||||
is_practice_exam=True
|
||||
)
|
||||
|
||||
on_course_publish(self.course.id)
|
||||
|
||||
requirements = get_credit_requirements(self.course.id)
|
||||
self.assertEqual(len(requirements), 1)
|
||||
|
||||
# make sure we don't have a proctoring requirement
|
||||
self.assertFalse([
|
||||
requirement
|
||||
for requirement in requirements
|
||||
if requirement['namespace'] == 'proctored_exam'
|
||||
])
|
||||
|
||||
def test_query_counts(self):
|
||||
self.add_credit_course(self.course.id)
|
||||
self.add_icrv_xblock()
|
||||
|
||||
@@ -56,9 +56,10 @@ git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b
|
||||
-e git+https://github.com/edx/edx-reverification-block.git@1e8f5a7fd589951a90bd31a0824a2c01ac9598ce#egg=edx-reverification-block
|
||||
git+https://github.com/edx/ecommerce-api-client.git@1.1.0#egg=ecommerce-api-client==1.1.0
|
||||
-e git+https://github.com/edx/edx-user-state-client.git@30c0ad4b9f57f8d48d6943eb585ec8a9205f4469#egg=edx-user-state-client
|
||||
-e git+https://github.com/edx/edx-proctoring.git@release-2015-07-29#egg=edx-proctoring
|
||||
-e git+https://github.com/edx/edx-organizations.git@release-2015-08-03#egg=edx-organizations
|
||||
|
||||
git+https://github.com/edx/edx-proctoring.git@release-2015-08-18#egg=edx-proctoring==0.6.0
|
||||
|
||||
# Third Party XBlocks
|
||||
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
|
||||
-e git+https://github.com/open-craft/xblock-poll@v1.0#egg=xblock-poll
|
||||
|
||||
Reference in New Issue
Block a user