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
|
This is typically called on a course published signal. The course is examined for sequences
|
||||||
that are marked as timed exams. Then these are registered with the edx-proctoring
|
that are marked as timed exams. Then these are registered with the edx-proctoring
|
||||||
subsystem. Likewise, if formerly registered exams are unmarked, then those
|
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'):
|
if not settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'):
|
||||||
@@ -76,6 +76,7 @@ def register_proctored_exams(course_key):
|
|||||||
exam_name=timed_exam.display_name,
|
exam_name=timed_exam.display_name,
|
||||||
time_limit_mins=timed_exam.default_time_limit_minutes,
|
time_limit_mins=timed_exam.default_time_limit_minutes,
|
||||||
is_proctored=timed_exam.is_proctored_enabled,
|
is_proctored=timed_exam.is_proctored_enabled,
|
||||||
|
is_practice_exam=timed_exam.is_practice_exam,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
msg = 'Updated timed exam {exam_id}'.format(exam_id=exam['id'])
|
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,
|
exam_name=timed_exam.display_name,
|
||||||
time_limit_mins=timed_exam.default_time_limit_minutes,
|
time_limit_mins=timed_exam.default_time_limit_minutes,
|
||||||
is_proctored=timed_exam.is_proctored_enabled,
|
is_proctored=timed_exam.is_proctored_enabled,
|
||||||
|
is_practice_exam=timed_exam.is_practice_exam,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
msg = 'Created new timed exam {exam_id}'.format(exam_id=exam_id)
|
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({
|
xblock_info.update({
|
||||||
"is_proctored_enabled": xblock.is_proctored_enabled,
|
"is_proctored_enabled": xblock.is_proctored_enabled,
|
||||||
"is_time_limited": xblock.is_time_limited,
|
"is_time_limited": xblock.is_time_limited,
|
||||||
"default_time_limit_minutes": xblock.default_time_limit_minutes
|
"default_time_limit_minutes": xblock.default_time_limit_minutes,
|
||||||
|
"is_practice_exam": xblock.is_practice_exam
|
||||||
})
|
})
|
||||||
|
|
||||||
# Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it.
|
# Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it.
|
||||||
|
|||||||
@@ -352,7 +352,6 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
|
|||||||
XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False)
|
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)
|
XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY)
|
||||||
|
|
||||||
|
|
||||||
################# PROCTORING CONFIGURATION ##################
|
################# PROCTORING CONFIGURATION ##################
|
||||||
|
|
||||||
PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER)
|
PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER)
|
||||||
|
|||||||
@@ -77,7 +77,8 @@
|
|||||||
"SUBDOMAIN_BRANDING": false,
|
"SUBDOMAIN_BRANDING": false,
|
||||||
"SUBDOMAIN_COURSE_LISTINGS": false,
|
"SUBDOMAIN_COURSE_LISTINGS": false,
|
||||||
"ALLOW_ALL_ADVANCED_COMPONENTS": true,
|
"ALLOW_ALL_ADVANCED_COMPONENTS": true,
|
||||||
"ENABLE_CONTENT_LIBRARIES": true
|
"ENABLE_CONTENT_LIBRARIES": true,
|
||||||
|
"ENABLE_PROCTORED_EXAMS": true
|
||||||
},
|
},
|
||||||
"FEEDBACK_SUBMISSION_EMAIL": "",
|
"FEEDBACK_SUBMISSION_EMAIL": "",
|
||||||
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ FEATURES['ENABLE_VIDEO_BUMPER'] = True # Enable video bumper in Studio settings
|
|||||||
########################### Entrance Exams #################################
|
########################### Entrance Exams #################################
|
||||||
FEATURES['ENTRANCE_EXAMS'] = True
|
FEATURES['ENTRANCE_EXAMS'] = True
|
||||||
|
|
||||||
|
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
|
||||||
|
|
||||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||||
YOUTUBE_PORT = 9080
|
YOUTUBE_PORT = 9080
|
||||||
YOUTUBE['API'] = "http://127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
|
YOUTUBE['API'] = "http://127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ FEATURES = {
|
|||||||
# Show video bumper in Studio
|
# Show video bumper in Studio
|
||||||
'ENABLE_VIDEO_BUMPER': False,
|
'ENABLE_VIDEO_BUMPER': False,
|
||||||
|
|
||||||
|
# Timed Proctored Exams
|
||||||
|
'ENABLE_PROCTORED_EXAMS': False,
|
||||||
|
|
||||||
# How many seconds to show the bumper again, default is 7 days:
|
# How many seconds to show the bumper again, default is 7 days:
|
||||||
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
|
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
|
||||||
|
|
||||||
@@ -775,6 +778,9 @@ INSTALLED_APPS = (
|
|||||||
'openedx.core.djangoapps.credit',
|
'openedx.core.djangoapps.credit',
|
||||||
|
|
||||||
'xblock_django',
|
'xblock_django',
|
||||||
|
|
||||||
|
# edX Proctoring
|
||||||
|
'edx_proctoring',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1040,11 +1046,10 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60
|
|||||||
|
|
||||||
DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended']
|
DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended']
|
||||||
|
|
||||||
|
|
||||||
#### PROCTORING CONFIGURATION DEFAULTS
|
#### PROCTORING CONFIGURATION DEFAULTS
|
||||||
|
|
||||||
PROCTORING_BACKEND_PROVIDER = {
|
PROCTORING_BACKEND_PROVIDER = {
|
||||||
'class': 'edx_proctoring.backends.NullBackendProvider',
|
'class': 'edx_proctoring.backends.null.NullBackendProvider',
|
||||||
'options': {},
|
'options': {},
|
||||||
}
|
}
|
||||||
PROCTORING_SETTINGS = {}
|
PROCTORING_SETTINGS = {}
|
||||||
|
|||||||
@@ -620,6 +620,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut
|
|||||||
has_explicit_staff_lock: true,
|
has_explicit_staff_lock: true,
|
||||||
staff_only_message: true,
|
staff_only_message: true,
|
||||||
"is_time_limited": true,
|
"is_time_limited": true,
|
||||||
|
"is_practice_exam": false,
|
||||||
"is_proctored_enabled": true,
|
"is_proctored_enabled": true,
|
||||||
"default_time_limit_minutes": 150
|
"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",
|
"start":"2014-07-09T00:00:00.000Z",
|
||||||
"due":"2014-07-10T00:00:00.000Z",
|
"due":"2014-07-10T00:00:00.000Z",
|
||||||
"is_time_limited": true,
|
"is_time_limited": true,
|
||||||
|
"is_practice_exam": false,
|
||||||
"is_proctored_enabled": true,
|
"is_proctored_enabled": true,
|
||||||
"default_time_limit_minutes": 150
|
"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($("#staff_lock").is(":checked")).toBe(true);
|
||||||
expect($("#id_timed_examination").is(":checked")).toBe(true);
|
expect($("#id_timed_examination").is(":checked")).toBe(true);
|
||||||
expect($("#id_exam_proctoring").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");
|
expect($("#id_time_limit").val()).toBe("02:30");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -275,11 +275,17 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!$(event.currentTarget).is(':checked')) {
|
if (!$(event.currentTarget).is(':checked')) {
|
||||||
this.$('#id_exam_proctoring').attr('checked', false);
|
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_exam_proctoring').attr('disabled','disabled');
|
||||||
this.$('#id_time_limit').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 {
|
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_exam_proctoring').removeAttr('disabled');
|
||||||
this.$('#id_time_limit').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);
|
AbstractEditor.prototype.afterRender.call(this);
|
||||||
this.$('input.time').timepicker({
|
this.$('input.time').timepicker({
|
||||||
'timeFormat' : 'H:i',
|
'timeFormat' : 'H:i',
|
||||||
|
'minTime': '00:30',
|
||||||
|
'maxTime': '05:00',
|
||||||
'forceRoundTime': false
|
'forceRoundTime': false
|
||||||
});
|
});
|
||||||
this.setExamTime(this.model.get('default_time_limit_minutes'));
|
this.setExamTime(this.model.get('default_time_limit_minutes'));
|
||||||
this.setExamTmePreference(this.model.get('is_time_limited'));
|
this.setExamTmePreference(this.model.get('is_time_limited'));
|
||||||
this.setExamProctoring(this.model.get('is_proctored_enabled'));
|
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) {
|
setExamProctoring: function(value) {
|
||||||
this.$('#id_exam_proctoring').prop('checked', 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')) {
|
if (!this.$('#id_timed_examination').is(':checked')) {
|
||||||
this.$('#id_exam_proctoring').attr('disabled','disabled');
|
this.$('#id_exam_proctoring').attr('disabled','disabled');
|
||||||
this.$('#id_time_limit').attr('disabled', 'disabled');
|
this.$('#id_time_limit').attr('disabled', 'disabled');
|
||||||
|
this.$('#id_practice_exam').attr('disabled', 'disabled');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isExamTimeEnabled: function () {
|
isExamTimeEnabled: function () {
|
||||||
return this.$('#id_timed_examination').is(':checked');
|
return this.$('#id_timed_examination').is(':checked');
|
||||||
},
|
},
|
||||||
|
isPracticeExam: function () {
|
||||||
|
return this.$('#id_practice_exam').is(':checked');
|
||||||
|
},
|
||||||
isValidTimeLimit: function(time_limit) {
|
isValidTimeLimit: function(time_limit) {
|
||||||
var pattern = new RegExp('^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$');
|
var pattern = new RegExp('^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$');
|
||||||
return pattern.test(time_limit);
|
return pattern.test(time_limit) && time_limit !== "00:00";
|
||||||
},
|
},
|
||||||
getExamTimeLimit: function () {
|
getExamTimeLimit: function () {
|
||||||
return this.$('#id_time_limit').val();
|
return this.$('#id_time_limit').val();
|
||||||
@@ -338,6 +354,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
|||||||
var time_limit = this.getExamTimeLimit();
|
var time_limit = this.getExamTimeLimit();
|
||||||
return {
|
return {
|
||||||
metadata: {
|
metadata: {
|
||||||
|
'is_practice_exam': this.isPracticeExam(),
|
||||||
'is_time_limited': this.isExamTimeEnabled(),
|
'is_time_limited': this.isExamTimeEnabled(),
|
||||||
'is_proctored_enabled': this.isExamProctoringEnabled(),
|
'is_proctored_enabled': this.isExamProctoringEnabled(),
|
||||||
'default_time_limit_minutes': this.convertTimeLimitToMinutes(time_limit)
|
'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>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<ul class="list-fields list-input">
|
||||||
|
<li class="field field-checkbox checkbox-cosmetic">
|
||||||
|
<input type="checkbox" id="id_practice_exam" name="practice_exam" class="input input-checkbox" />
|
||||||
|
<label for="id_practice_exam" class="label">
|
||||||
|
<i class="icon fa fa-check-square-o input-checkbox-checked"></i>
|
||||||
|
<i class="icon fa fa-square-o input-checkbox-unchecked"></i>
|
||||||
|
<%- gettext('This exam is for practice only') %>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<p class='field-message'> <%- gettext('Learners can experience the proctoring software setup process and try some example problems. Make sure this practice exam is set up as an ungraded exam.') %> </p>
|
||||||
|
</ul>
|
||||||
<ul class="list-fields list-input">
|
<ul class="list-fields list-input">
|
||||||
<li class="field field-checkbox checkbox-cosmetic">
|
<li class="field field-checkbox checkbox-cosmetic">
|
||||||
<input type="checkbox" id="id_exam_proctoring" name="exam_proctoring" class="input input-checkbox" />
|
<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 json
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
from xblock.core import XBlock
|
||||||
from xblock.fields import Integer, Scope, Boolean
|
from xblock.fields import Integer, Scope, Boolean
|
||||||
from xblock.fragment import Fragment
|
from xblock.fragment import Fragment
|
||||||
from pkg_resources import resource_string
|
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
|
''' Layout module which lays out content in a temporal sequence
|
||||||
'''
|
'''
|
||||||
js = {
|
js = {
|
||||||
@@ -141,6 +150,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disa
|
|||||||
else:
|
else:
|
||||||
self.position = 1
|
self.position = 1
|
||||||
return json.dumps({'success': True})
|
return json.dumps({'success': True})
|
||||||
|
|
||||||
raise NotFoundError('Unexpected dispatch type')
|
raise NotFoundError('Unexpected dispatch type')
|
||||||
|
|
||||||
def student_view(self, context):
|
def student_view(self, context):
|
||||||
@@ -154,6 +164,16 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disa
|
|||||||
|
|
||||||
fragment = Fragment()
|
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():
|
for child in self.get_display_items():
|
||||||
progress = child.get_progress()
|
progress = child.get_progress()
|
||||||
rendered_child = child.render(STUDENT_VIEW, context)
|
rendered_child = child.render(STUDENT_VIEW, context)
|
||||||
@@ -181,10 +201,72 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disa
|
|||||||
'ajax_url': self.system.ajax_url,
|
'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
|
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):
|
def get_icon_class(self):
|
||||||
child_classes = set(child.get_icon_class()
|
child_classes = set(child.get_icon_class()
|
||||||
for child in self.get_children())
|
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]
|
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):
|
class CoursewareSequentialTabPage(CoursePage):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ class InstructorDashboardPage(CoursePage):
|
|||||||
certificates_section.wait_for_page()
|
certificates_section.wait_for_page()
|
||||||
return certificates_section
|
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
|
@staticmethod
|
||||||
def get_asset_path(file_name):
|
def get_asset_path(file_name):
|
||||||
"""
|
"""
|
||||||
@@ -105,6 +114,40 @@ class MembershipPage(PageObject):
|
|||||||
return MembershipPageAutoEnrollSection(self.browser)
|
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):
|
class CohortManagementSection(PageObject):
|
||||||
"""
|
"""
|
||||||
The Cohort Management section of the Instructor dashboard.
|
The Cohort Management section of the Instructor dashboard.
|
||||||
@@ -707,6 +750,55 @@ class MembershipPageAutoEnrollSection(PageObject):
|
|||||||
self.click_upload_file_button()
|
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):
|
class DataDownloadPage(PageObject):
|
||||||
"""
|
"""
|
||||||
Data Download section of the Instructor dashboard.
|
Data Download section of the Instructor dashboard.
|
||||||
|
|||||||
@@ -513,6 +513,60 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
|||||||
"""
|
"""
|
||||||
self.reindex_button.click()
|
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
|
@property
|
||||||
def bottom_add_section_button(self):
|
def bottom_add_section_button(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import time
|
|||||||
|
|
||||||
from ..helpers import UniqueCourseTest
|
from ..helpers import UniqueCourseTest
|
||||||
from ...pages.studio.auto_auth import AutoAuthPage
|
from ...pages.studio.auto_auth import AutoAuthPage
|
||||||
|
from ...pages.lms.create_mode import ModeCreationPage
|
||||||
from ...pages.studio.overview import CourseOutlinePage
|
from ...pages.studio.overview import CourseOutlinePage
|
||||||
from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage
|
from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage
|
||||||
from ...pages.lms.course_nav import CourseNavPage
|
from ...pages.lms.course_nav import CourseNavPage
|
||||||
from ...pages.lms.problem import ProblemPage
|
from ...pages.lms.problem import ProblemPage
|
||||||
from ...pages.common.logout import LogoutPage
|
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
|
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||||
|
|
||||||
|
|
||||||
@@ -63,14 +67,6 @@ class CoursewareTest(UniqueCourseTest):
|
|||||||
self.problem_page = ProblemPage(self.browser)
|
self.problem_page = ProblemPage(self.browser)
|
||||||
self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 1')
|
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):
|
def _auto_auth(self, username, email, staff):
|
||||||
"""
|
"""
|
||||||
Logout and login with given credentials.
|
Logout and login with given credentials.
|
||||||
@@ -94,10 +90,7 @@ class CoursewareTest(UniqueCourseTest):
|
|||||||
self.course_outline.visit()
|
self.course_outline.visit()
|
||||||
|
|
||||||
# Set release date for subsection in future.
|
# Set release date for subsection in future.
|
||||||
self._change_problem_release_date_in_studio()
|
self.course_outline.change_problem_release_date_in_studio()
|
||||||
|
|
||||||
# Wait for 2 seconds to save new date.
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# Logout and login as a student.
|
# Logout and login as a student.
|
||||||
LogoutPage(self.browser).visit()
|
LogoutPage(self.browser).visit()
|
||||||
@@ -109,6 +102,158 @@ class CoursewareTest(UniqueCourseTest):
|
|||||||
self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 2')
|
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):
|
class CoursewareMultipleVerticalsTest(UniqueCourseTest):
|
||||||
"""
|
"""
|
||||||
Test courseware with multiple verticals
|
Test courseware with multiple verticals
|
||||||
|
|||||||
@@ -3,14 +3,23 @@
|
|||||||
End-to-end tests for the LMS Instructor Dashboard.
|
End-to-end tests for the LMS Instructor Dashboard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
from nose.plugins.attrib import attr
|
from nose.plugins.attrib import attr
|
||||||
from bok_choy.promise import EmptyPromise
|
from bok_choy.promise import EmptyPromise
|
||||||
|
|
||||||
from ..helpers import UniqueCourseTest, get_modal_alert, EventsTestMixin
|
from ..helpers import UniqueCourseTest, get_modal_alert, EventsTestMixin
|
||||||
from ...pages.common.logout import LogoutPage
|
from ...pages.common.logout import LogoutPage
|
||||||
from ...pages.lms.auto_auth import AutoAuthPage
|
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 ...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):
|
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.")
|
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')
|
@attr('shard_5')
|
||||||
class EntranceExamGradeTest(BaseInstructorDashboardTest):
|
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 util import milestones_helpers
|
||||||
from verify_student.services import ReverificationService
|
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
|
from .field_overrides import OverrideFieldData
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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)
|
section.url_name == active_section)
|
||||||
|
|
||||||
if not section.hide_from_toc:
|
if not section.hide_from_toc:
|
||||||
sections.append({'display_name': section.display_name_with_default,
|
section_context = {
|
||||||
'url_name': section.url_name,
|
'display_name': section.display_name_with_default,
|
||||||
'format': section.format if section.format is not None else '',
|
'url_name': section.url_name,
|
||||||
'due': section.due,
|
'format': section.format if section.format is not None else '',
|
||||||
'active': active,
|
'due': section.due,
|
||||||
'graded': section.graded,
|
'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({
|
toc_chapters.append({
|
||||||
'display_name': chapter.display_name_with_default,
|
'display_name': chapter.display_name_with_default,
|
||||||
'url_name': chapter.url_name,
|
'url_name': chapter.url_name,
|
||||||
@@ -678,7 +737,9 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to
|
|||||||
'fs': FSService(),
|
'fs': FSService(),
|
||||||
'field-data': field_data,
|
'field-data': field_data,
|
||||||
'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff),
|
'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),
|
get_user_role=lambda: get_user_role(user, course_id),
|
||||||
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access
|
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.modulestore.tests.factories import ItemFactory, CourseFactory, check_mongo_calls
|
||||||
from xmodule.x_module import XModuleDescriptor, XModule, STUDENT_VIEW, CombinedSystem
|
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
|
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||||
|
|
||||||
|
|
||||||
@@ -682,6 +696,327 @@ class TestTOC(ModuleStoreTestCase):
|
|||||||
self.assertIn(toc_section, actual)
|
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')
|
@attr('shard_1')
|
||||||
@ddt.ddt
|
@ddt.ddt
|
||||||
class TestHtmlModifiers(ModuleStoreTestCase):
|
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',
|
'instructor_api_endpoint': 'get_students_who_may_enroll',
|
||||||
'task_api_endpoint': 'instructor_task.api.submit_calculate_may_enroll_csv',
|
'task_api_endpoint': 'instructor_task.api.submit_calculate_may_enroll_csv',
|
||||||
'extra_instructor_api_kwargs': {},
|
'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_enrollment_report', {}),
|
||||||
('get_students_who_may_enroll', {}),
|
('get_students_who_may_enroll', {}),
|
||||||
('get_exec_summary_report', {}),
|
('get_exec_summary_report', {}),
|
||||||
|
('get_proctored_exam_results', {}),
|
||||||
]
|
]
|
||||||
# Endpoints that only Instructors can access
|
# Endpoints that only Instructors can access
|
||||||
self.instructor_level_endpoints = [
|
self.instructor_level_endpoints = [
|
||||||
@@ -2316,6 +2323,29 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
|
|||||||
self.assertIn('status', res_json)
|
self.assertIn('status', res_json)
|
||||||
self.assertIn('currently being created', res_json['status'])
|
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):
|
def test_access_course_finance_admin_with_invalid_course_key(self):
|
||||||
"""
|
"""
|
||||||
Test assert require_course fiance_admin before generating
|
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):
|
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
|
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',
|
url(r'^show_student_extensions$', 'instructor.views.api.show_student_extensions',
|
||||||
name='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...
|
# Grade downloads...
|
||||||
url(r'^list_report_downloads$',
|
url(r'^list_report_downloads$',
|
||||||
'instructor.views.api.list_report_downloads', name="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']):
|
if course_mode_has_price and (access['finance_admin'] or access['sales_admin']):
|
||||||
sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label, is_white_label))
|
sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label, is_white_label))
|
||||||
|
|
||||||
|
# Gate access to Proctoring tab
|
||||||
|
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and course.enable_proctored_exams:
|
||||||
|
sections.append(_section_proctoring(course, access))
|
||||||
|
|
||||||
# Certificates panel
|
# Certificates panel
|
||||||
# This is used to generate example certificates
|
# This is used to generate example certificates
|
||||||
# and enable self-generated certificates for a course.
|
# 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
|
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):
|
def _section_certificates(course):
|
||||||
"""Section information for the certificates panel.
|
"""Section information for the certificates panel.
|
||||||
|
|
||||||
@@ -467,12 +484,14 @@ def _section_data_download(course, access):
|
|||||||
'section_key': 'data_download',
|
'section_key': 'data_download',
|
||||||
'section_display_name': _('Data Download'),
|
'section_display_name': _('Data Download'),
|
||||||
'access': access,
|
'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_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_features_url': reverse('get_students_features', kwargs={'course_id': unicode(course_key)}),
|
||||||
'get_students_who_may_enroll_url': reverse(
|
'get_students_who_may_enroll_url': reverse(
|
||||||
'get_students_who_may_enroll', kwargs={'course_id': unicode(course_key)}
|
'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)}),
|
'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_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)}),
|
'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)}),
|
'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 django.core.exceptions import ObjectDoesNotExist
|
||||||
from microsite_configuration import microsite
|
from microsite_configuration import microsite
|
||||||
from student.models import CourseEnrollmentAllowed
|
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')
|
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]
|
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):
|
def coupon_codes_features(features, coupons_list, course_id):
|
||||||
"""
|
"""
|
||||||
Return list of Coupon Codes as dictionaries.
|
Return list of Coupon Codes as dictionaries.
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ Tests for instructor.basic
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import datetime
|
||||||
|
from django.db.models import Q
|
||||||
|
import pytz
|
||||||
from student.models import CourseEnrollment, CourseEnrollmentAllowed
|
from student.models import CourseEnrollment, CourseEnrollmentAllowed
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from mock import patch
|
from mock import patch
|
||||||
@@ -16,16 +19,14 @@ from course_modes.models import CourseMode
|
|||||||
from instructor_analytics.basic import (
|
from instructor_analytics.basic import (
|
||||||
sale_record_features, sale_order_record_features, enrolled_students_features,
|
sale_record_features, sale_order_record_features, enrolled_students_features,
|
||||||
course_registration_features, coupon_codes_features, list_may_enroll,
|
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 openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
|
||||||
from courseware.tests.factories import InstructorFactory
|
from courseware.tests.factories import InstructorFactory
|
||||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||||
from xmodule.modulestore.tests.factories import CourseFactory
|
from xmodule.modulestore.tests.factories import CourseFactory
|
||||||
|
from edx_proctoring.api import create_exam
|
||||||
import datetime
|
from edx_proctoring.models import ProctoredExamStudentAttempt
|
||||||
from django.db.models import Q
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsBasic(ModuleStoreTestCase):
|
class TestAnalyticsBasic(ModuleStoreTestCase):
|
||||||
@@ -127,6 +128,40 @@ class TestAnalyticsBasic(ModuleStoreTestCase):
|
|||||||
self.assertEqual(student.keys(), ['email'])
|
self.assertEqual(student.keys(), ['email'])
|
||||||
self.assertIn(student['email'], email_adresses)
|
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})
|
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
|
||||||
class TestCourseSaleRecordsAnalyticsBasic(ModuleStoreTestCase):
|
class TestCourseSaleRecordsAnalyticsBasic(ModuleStoreTestCase):
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from instructor_task.tasks import (
|
|||||||
calculate_may_enroll_csv,
|
calculate_may_enroll_csv,
|
||||||
exec_summary_report_csv,
|
exec_summary_report_csv,
|
||||||
generate_certificates,
|
generate_certificates,
|
||||||
|
proctored_exam_results_csv
|
||||||
)
|
)
|
||||||
|
|
||||||
from instructor_task.api_helper import (
|
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)
|
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):
|
def submit_cohort_students(request, course_key, file_name):
|
||||||
"""
|
"""
|
||||||
Request to have students cohorted in bulk.
|
Request to have students cohorted in bulk.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from instructor_task.tasks_helper import (
|
|||||||
upload_may_enroll_csv,
|
upload_may_enroll_csv,
|
||||||
upload_exec_summary_report,
|
upload_exec_summary_report,
|
||||||
generate_students_certificates,
|
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)
|
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
|
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable
|
||||||
def calculate_may_enroll_csv(entry_id, xmodule_instance_args):
|
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.models import StudentModule
|
||||||
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
|
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
|
||||||
from courseware.module_render import get_module_for_descriptor_internal
|
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_analytics.csvs import format_dictlist
|
||||||
from instructor_task.models import ReportStore, InstructorTask, PROGRESS
|
from instructor_task.models import ReportStore, InstructorTask, PROGRESS
|
||||||
from lms.djangoapps.lms_xblock.runtime import LmsPartitionService
|
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,
|
For a given `course_id`, generate a html report containing information,
|
||||||
which provides a snapshot of how the course is doing.
|
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)
|
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(
|
def generate_students_certificates(
|
||||||
_xmodule_instance_args, _entry_id, course_id, task_input, action_name): # pylint: disable=unused-argument
|
_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['ENABLE_INSTRUCTOR_EMAIL'] = True
|
||||||
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False
|
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False
|
||||||
|
|
||||||
|
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
|
||||||
|
|
||||||
# Don't actually send any requests to Software Secure for student identity
|
# Don't actually send any requests to Software Secure for student identity
|
||||||
# verification.
|
# verification.
|
||||||
FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True
|
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
|
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||||
INSTALLED_APPS += ('lettuce.django',)
|
INSTALLED_APPS += ('lettuce.django',)
|
||||||
LETTUCE_APPS = ('courseware', 'instructor',)
|
LETTUCE_APPS = ('courseware', 'instructor')
|
||||||
|
|
||||||
# Lettuce appears to have a bug that causes it to search
|
# Lettuce appears to have a bug that causes it to search
|
||||||
# `instructor_task` when we specify the `instructor` app.
|
# `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 Provider help link ####################
|
||||||
CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_URL)
|
CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_URL)
|
||||||
|
|
||||||
|
|
||||||
#### JWT configuration ####
|
#### JWT configuration ####
|
||||||
JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER)
|
JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER)
|
||||||
JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION)
|
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,
|
"AUTOMATIC_AUTH_FOR_TESTING": true,
|
||||||
"MODE_CREATION_FOR_TESTING": true,
|
"MODE_CREATION_FOR_TESTING": true,
|
||||||
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true,
|
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true,
|
||||||
"ENABLE_COURSE_DISCOVERY": true
|
"ENABLE_COURSE_DISCOVERY": true,
|
||||||
|
"ENABLE_PROCTORED_EXAMS": true
|
||||||
},
|
},
|
||||||
"FEEDBACK_SUBMISSION_EMAIL": "",
|
"FEEDBACK_SUBMISSION_EMAIL": "",
|
||||||
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ FEATURES['LICENSING'] = True
|
|||||||
FEATURES['MILESTONES_APP'] = True
|
FEATURES['MILESTONES_APP'] = True
|
||||||
FEATURES['ENTRANCE_EXAMS'] = True
|
FEATURES['ENTRANCE_EXAMS'] = True
|
||||||
|
|
||||||
|
FEATURES['ENABLE_PROCTORED_EXAMS'] = True
|
||||||
|
|
||||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||||
YOUTUBE_PORT = 9080
|
YOUTUBE_PORT = 9080
|
||||||
YOUTUBE['API'] = "http://127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
|
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:
|
# How many seconds to show the bumper again, default is 7 days:
|
||||||
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
|
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
|
||||||
|
|
||||||
|
# Timed Proctored Exams
|
||||||
|
'ENABLE_PROCTORED_EXAMS': False,
|
||||||
|
|
||||||
# Enable OpenBadge support. See the BADGR_* settings later in this file.
|
# Enable OpenBadge support. See the BADGR_* settings later in this file.
|
||||||
'ENABLE_OPENBADGES': False,
|
'ENABLE_OPENBADGES': False,
|
||||||
|
|
||||||
@@ -1126,7 +1129,7 @@ TEMPLATE_LOADERS = (
|
|||||||
'edxmako.makoloader.MakoAppDirectoriesLoader',
|
'edxmako.makoloader.MakoAppDirectoriesLoader',
|
||||||
|
|
||||||
# 'django.template.loaders.filesystem.Loader',
|
# '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'))
|
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
|
# Before a student accesses courseware, we do not
|
||||||
# need many of the JS dependencies. This includes
|
# need many of the JS dependencies. This includes
|
||||||
@@ -1511,6 +1520,10 @@ PIPELINE_JS = {
|
|||||||
],
|
],
|
||||||
'output_filename': 'js/lms-application.js',
|
'output_filename': 'js/lms-application.js',
|
||||||
},
|
},
|
||||||
|
'proctoring': {
|
||||||
|
'source_filenames': proctoring_js,
|
||||||
|
'output_filename': 'js/lms-proctoring.js',
|
||||||
|
},
|
||||||
'courseware': {
|
'courseware': {
|
||||||
'source_filenames': courseware_js,
|
'source_filenames': courseware_js,
|
||||||
'output_filename': 'js/lms-courseware.js',
|
'output_filename': 'js/lms-courseware.js',
|
||||||
@@ -2606,3 +2619,11 @@ JWT_ISSUER = None
|
|||||||
# Credit notifications settings
|
# Credit notifications settings
|
||||||
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
|
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
|
||||||
NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png"
|
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
|
# Force settings to run so that the python path is modified
|
||||||
settings.INSTALLED_APPS # pylint: disable=pointless-statement
|
settings.INSTALLED_APPS # pylint: disable=pointless-statement
|
||||||
|
|
||||||
|
from instructor.services import InstructorService
|
||||||
|
|
||||||
from openedx.core.lib.django_startup import autostartup
|
from openedx.core.lib.django_startup import autostartup
|
||||||
import edxmako
|
import edxmako
|
||||||
import logging
|
import logging
|
||||||
from monkey_patch import django_utils_translation
|
from monkey_patch import django_utils_translation
|
||||||
import analytics
|
import analytics
|
||||||
|
|
||||||
|
from edx_proctoring.runtime import set_runtime_service
|
||||||
|
from openedx.core.djangoapps.credit.services import CreditService
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -43,6 +47,13 @@ def run():
|
|||||||
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
|
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
|
||||||
analytics.init(settings.SEGMENT_IO_LMS_KEY, flush_at=50)
|
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():
|
def add_mimetypes():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class DataDownload
|
|||||||
# gather elements
|
# gather elements
|
||||||
@$list_studs_btn = @$section.find("input[name='list-profiles']'")
|
@$list_studs_btn = @$section.find("input[name='list-profiles']'")
|
||||||
@$list_studs_csv_btn = @$section.find("input[name='list-profiles-csv']'")
|
@$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_may_enroll_csv_btn = @$section.find("input[name='list-may-enroll-csv']")
|
||||||
@$list_anon_btn = @$section.find("input[name='list-anon-ids']'")
|
@$list_anon_btn = @$section.find("input[name='list-anon-ids']'")
|
||||||
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
|
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
|
||||||
@@ -45,6 +46,25 @@ class DataDownload
|
|||||||
url = @$list_anon_btn.data 'endpoint'
|
url = @$list_anon_btn.data 'endpoint'
|
||||||
location.href = url
|
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
|
# this handler binds to both the download
|
||||||
# and the csv button
|
# and the csv button
|
||||||
@$list_studs_csv_btn.click (e) =>
|
@$list_studs_csv_btn.click (e) =>
|
||||||
|
|||||||
@@ -184,6 +184,16 @@ setup_instructor_dashboard_sections = (idash_content) ->
|
|||||||
$element: idash_content.find ".#{CSS_IDASH_SECTION}#certificates"
|
$element: idash_content.find ".#{CSS_IDASH_SECTION}#certificates"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# proctoring can be feature disabled
|
||||||
|
if edx.instructor_dashboard.proctoring != undefined
|
||||||
|
sections_to_initialize = sections_to_initialize.concat [
|
||||||
|
constructor: edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView
|
||||||
|
$element: idash_content.find ".#{CSS_IDASH_SECTION}#proctoring"
|
||||||
|
,
|
||||||
|
constructor: edx.instructor_dashboard.proctoring.ProctoredExamAttemptView
|
||||||
|
$element: idash_content.find ".#{CSS_IDASH_SECTION}#proctoring"
|
||||||
|
]
|
||||||
|
|
||||||
sections_to_initialize.map ({constructor, $element}) ->
|
sections_to_initialize.map ({constructor, $element}) ->
|
||||||
# See fault isolation NOTE at top of file.
|
# See fault isolation NOTE at top of file.
|
||||||
# If an error is thrown in one section, it will not stop other sections from exectuing.
|
# 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%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.video-fullscreen{
|
html.video-fullscreen {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
body{
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,210 @@ div.course-wrapper {
|
|||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 lh();
|
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 {
|
p {
|
||||||
margin-bottom: lh();
|
margin-bottom: lh();
|
||||||
}
|
}
|
||||||
@@ -197,8 +400,8 @@ div.course-wrapper {
|
|||||||
|
|
||||||
nav.sequence-bottom {
|
nav.sequence-bottom {
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +463,7 @@ div.course-wrapper {
|
|||||||
}
|
}
|
||||||
p.success {
|
p.success {
|
||||||
color: $success-color;
|
color: $success-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.staff_info {
|
div.staff_info {
|
||||||
@@ -318,14 +521,14 @@ div.course-wrapper {
|
|||||||
width: 10px;
|
width: 10px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,46 +547,45 @@ div.course-wrapper {
|
|||||||
.xmodule_VideoModule {
|
.xmodule_VideoModule {
|
||||||
margin-bottom: ($baseline*1.5);
|
margin-bottom: ($baseline*1.5);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.short-form-response {
|
textarea.short-form-response {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
padding: ($baseline/4);
|
padding: ($baseline/4);
|
||||||
margin-top: ($baseline/4);
|
margin-top: ($baseline/4);
|
||||||
margin-bottom: ($baseline/4);
|
margin-bottom: ($baseline/4);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
section.self-assessment {
|
section.self-assessment {
|
||||||
textarea.hint {
|
textarea.hint {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
padding: ($baseline/4);
|
padding: ($baseline/4);
|
||||||
margin-top: ($baseline/4);
|
margin-top: ($baseline/4);
|
||||||
margin-bottom: ($baseline/4);
|
margin-bottom: ($baseline/4);
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
div {
|
||||||
margin-top: ($baseline/4);
|
margin-top: ($baseline/4);
|
||||||
margin-bottom: ($baseline/4);
|
margin-bottom: ($baseline/4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
section.foldit {
|
section.foldit {
|
||||||
table {
|
table {
|
||||||
margin-top: ($baseline/2);
|
margin-top: ($baseline/2);
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
padding-left: ($baseline/4);
|
padding-left: ($baseline/4);
|
||||||
padding-right: ($baseline/4);
|
padding-right: ($baseline/4);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,19 @@
|
|||||||
&:empty {
|
&:empty {
|
||||||
display: none;
|
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{
|
h2{
|
||||||
height: 26px;
|
height: 26px;
|
||||||
line-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 .olddash-button-wrapper,
|
||||||
.rtl .instructor-dashboard-wrapper-2 .studio-edit-link {
|
.rtl .instructor-dashboard-wrapper-2 .studio-edit-link {
|
||||||
|
|||||||
@@ -47,3 +47,47 @@
|
|||||||
font-size: 90%;
|
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)
|
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)
|
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']:
|
% 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>
|
<i class="icon fa fa-pencil-square-o" aria-hidden="true" data-tooltip="${_("This section is graded.")}"></i>
|
||||||
% endif
|
% endif
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
## mako
|
## mako
|
||||||
|
<%namespace name='static' file='/static_content.html'/>
|
||||||
<%!
|
<%!
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from courseware.tabs import get_course_tab_list
|
from courseware.tabs import get_course_tab_list
|
||||||
from courseware.views import notification_image_for_tab
|
from courseware.views import notification_image_for_tab
|
||||||
from django.core.urlresolvers import reverse
|
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 openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
|
||||||
from student.models import CourseEnrollment
|
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")
|
staff_selected = selected(not masquerade or masquerade.role != "student")
|
||||||
specific_student_selected = selected(not staff_selected and masquerade.user_name)
|
specific_student_selected = selected(not staff_selected and masquerade.user_name)
|
||||||
student_selected = selected(not staff_selected and not specific_student_selected and not masquerade_group_id)
|
student_selected = selected(not staff_selected and not specific_student_selected and not masquerade_group_id)
|
||||||
|
include_proctoring = settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and course.enable_proctored_exams
|
||||||
%>
|
%>
|
||||||
|
<%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:
|
% if show_preview_menu:
|
||||||
<nav class="wrapper-preview-menu" aria-label="${_('Course View')}">
|
<nav class="wrapper-preview-menu" aria-label="${_('Course View')}">
|
||||||
<div class="preview-menu">
|
<div class="preview-menu">
|
||||||
|
|||||||
@@ -4,8 +4,12 @@
|
|||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.template.defaultfilters import escapejs
|
from django.template.defaultfilters import escapejs
|
||||||
from microsite_configuration import page_title_breadcrumbs
|
from microsite_configuration import page_title_breadcrumbs
|
||||||
|
from django.conf import settings
|
||||||
from edxnotes.helpers import is_feature_enabled as is_edxnotes_enabled
|
from edxnotes.helpers import is_feature_enabled as is_edxnotes_enabled
|
||||||
%>
|
%>
|
||||||
|
<%
|
||||||
|
include_proctoring = settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and course.enable_proctored_exams
|
||||||
|
%>
|
||||||
<%def name="course_name()">
|
<%def name="course_name()">
|
||||||
<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
|
<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
|
||||||
</%def>
|
</%def>
|
||||||
@@ -25,13 +29,23 @@ ${page_title_breadcrumbs(course_name())}
|
|||||||
<%static:include path="common/templates/${template_name}.underscore" />
|
<%static:include path="common/templates/${template_name}.underscore" />
|
||||||
</script>
|
</script>
|
||||||
% endfor
|
% endfor
|
||||||
|
|
||||||
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
|
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
|
||||||
% for template_name in ["course_search_item", "course_search_results", "search_loading", "search_error"]:
|
% for template_name in ["course_search_item", "course_search_results", "search_loading", "search_error"]:
|
||||||
<script type="text/template" id="${template_name}-tpl">
|
<script type="text/template" id="${template_name}-tpl">
|
||||||
<%static:include path="search/${template_name}.underscore" />
|
<%static:include path="search/${template_name}.underscore" />
|
||||||
</script>
|
</script>
|
||||||
% endfor
|
% endfor
|
||||||
% endif
|
% 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>
|
||||||
|
|
||||||
<%block name="headextra">
|
<%block name="headextra">
|
||||||
@@ -81,7 +95,7 @@ ${page_title_breadcrumbs(course_name())}
|
|||||||
var $$course_id = "${course.id | escapejs}";
|
var $$course_id = "${course.id | escapejs}";
|
||||||
|
|
||||||
$(function(){
|
$(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 elemText = $(this).text().replace(/^\s+|\s+$/g,''); // Strip leading and trailing whitespace
|
||||||
var wordArray = elemText.split(" ");
|
var wordArray = elemText.split(" ");
|
||||||
var finalTitle = "";
|
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>
|
<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:
|
% if not disable_buttons:
|
||||||
<p>${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}</p>
|
<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>
|
<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 = { };
|
window.Range.prototype = { };
|
||||||
}
|
}
|
||||||
</script>
|
</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/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.js')}"></script>
|
||||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var sequenceNav;
|
var sequenceNav;
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block bodyclass %}view-incourse view-wiki{% endblock %}
|
{% block bodyclass %}view-incourse view-wiki{% endblock %}
|
||||||
|
|
||||||
{% block headextra %}
|
{% block headextra %}
|
||||||
|
<script type="text/javascript" src="/i18n.js"></script>
|
||||||
{% compressed_css 'course' %}
|
{% compressed_css 'course' %}
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|||||||
@@ -711,3 +711,8 @@ urlpatterns += (
|
|||||||
url(r'^404$', handler404),
|
url(r'^404$', handler404),
|
||||||
url(r'^500$', handler500),
|
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 = [
|
requirements = [
|
||||||
{
|
{
|
||||||
'namespace': 'proctored_exam',
|
'namespace': 'proctored_exam',
|
||||||
'name': 'proctored_exam_id:{id}'.format(id=exam['id']),
|
'name': exam['content_id'],
|
||||||
'display_name': exam['exam_name'],
|
'display_name': exam['exam_name'],
|
||||||
'criteria': {},
|
'criteria': {},
|
||||||
}
|
}
|
||||||
for exam in get_all_exams_for_course(unicode(course_key))
|
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 = (
|
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(len(requirements), 1)
|
||||||
self.assertEqual(requirements[0]['namespace'], 'proctored_exam')
|
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]['display_name'], 'A Proctored Exam')
|
||||||
self.assertEqual(requirements[0]['criteria'], {})
|
self.assertEqual(requirements[0]['criteria'], {})
|
||||||
|
|
||||||
def test_proctored_exam_filtering(self):
|
def test_proctored_exam_filtering(self):
|
||||||
"""
|
"""
|
||||||
Make sure that timed or inactive exams do not end up in the requirements table
|
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)
|
self.add_credit_course(self.course.id)
|
||||||
@@ -180,6 +181,29 @@ class TestTaskExecution(ModuleStoreTestCase):
|
|||||||
if requirement['namespace'] == 'proctored_exam'
|
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):
|
def test_query_counts(self):
|
||||||
self.add_credit_course(self.course.id)
|
self.add_credit_course(self.course.id)
|
||||||
self.add_icrv_xblock()
|
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
|
-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
|
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-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
|
-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
|
# Third Party XBlocks
|
||||||
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
|
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
|
||||||
-e git+https://github.com/open-craft/xblock-poll@v1.0#egg=xblock-poll
|
-e git+https://github.com/open-craft/xblock-poll@v1.0#egg=xblock-poll
|
||||||
|
|||||||
Reference in New Issue
Block a user