Commit 6cf5516a by Chris Dodge

Integration of edx_proctoring into the LMS

parent 5b9b0a83
...@@ -28,7 +28,7 @@ def register_proctored_exams(course_key): ...@@ -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): ...@@ -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): ...@@ -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 ...@@ -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', {}) ...@@ -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 @@ ...@@ -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 ...@@ -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 = { ...@@ -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 = ( ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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', ...@@ -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', ...@@ -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', ...@@ -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', ...@@ -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)
......
...@@ -26,6 +26,17 @@ ...@@ -26,6 +26,17 @@
</div> </div>
<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_practice_exam" name="practice_exam" class="input input-checkbox" />
<label for="id_practice_exam" class="label">
<i class="icon fa fa-check-square-o input-checkbox-checked"></i>
<i class="icon fa fa-square-o input-checkbox-unchecked"></i>
<%- gettext('This exam is for practice only') %>
</label>
</li>
<p class='field-message'> <%- gettext('Learners can experience the proctoring software setup process and try some example problems. Make sure this practice exam is set up as an ungraded exam.') %> </p>
</ul>
<ul class="list-fields list-input">
<li class="field field-checkbox checkbox-cosmetic">
<input type="checkbox" id="id_exam_proctoring" name="exam_proctoring" class="input input-checkbox" /> <input type="checkbox" id="id_exam_proctoring" name="exam_proctoring" class="input input-checkbox" />
<label for="id_exam_proctoring" class="label"> <label for="id_exam_proctoring" class="label">
<i class="icon fa fa-check-square-o input-checkbox-checked"></i> <i class="icon fa fa-check-square-o input-checkbox-checked"></i>
......
"""
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): ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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 @@ ...@@ -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): ...@@ -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 ...@@ -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_ ...@@ -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 ...@@ -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
......
"""
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 = ( ...@@ -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): ...@@ -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 ...@@ -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
......
"""
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)
"""
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): ...@@ -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( ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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 ...@@ -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 ...@@ -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): ...@@ -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 ( ...@@ -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 ...@@ -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 ( ...@@ -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
) )
...@@ -214,6 +215,17 @@ def exec_summary_report_csv(entry_id, xmodule_instance_args): ...@@ -214,6 +215,17 @@ def exec_summary_report_csv(entry_id, xmodule_instance_args):
@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 proctored_exam_results_csv(entry_id, xmodule_instance_args):
"""
Compute proctored exam results report for a course and upload the
CSV for download.
"""
action_name = 'generating_proctored_exam_results_report'
task_fn = partial(upload_proctored_exam_results_report, xmodule_instance_args)
return run_main_task(entry_id, task_fn, action_name)
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable
def calculate_may_enroll_csv(entry_id, xmodule_instance_args): def calculate_may_enroll_csv(entry_id, xmodule_instance_args):
""" """
Compute information about invited students who have not enrolled Compute information about invited students who have not enrolled
......
...@@ -46,7 +46,7 @@ from courseware.grades import iterate_grades_for ...@@ -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): ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 @@ ...@@ -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 ...@@ -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 = { ...@@ -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 = ( ...@@ -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 = ( ...@@ -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 = { ...@@ -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 ...@@ -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 ...@@ -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(): ...@@ -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 ...@@ -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 ...@@ -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) -> ...@@ -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.
......
$(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
}
);
});
\ No newline at end of file
...@@ -3,10 +3,10 @@ html { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 @@ ...@@ -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"] { ...@@ -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"] { ...@@ -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 @@ ...@@ -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 @@ ...@@ -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>&nbsp;
<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>
......
## 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 ...@@ -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 @@ ...@@ -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())} ...@@ -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">
<%static:include path="search/${template_name}.underscore" />
</script>
% endfor
% endif
% if include_proctoring:
% for template_name in ["proctored-exam-status"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="search/${template_name}.underscore" /> <%static:include path="courseware/${template_name}.underscore" />
</script> </script>
% endfor % endfor
% endif % endif
</%block> </%block>
<%block name="headextra"> <%block name="headextra">
...@@ -81,7 +95,7 @@ ${page_title_breadcrumbs(course_name())} ...@@ -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 = "";
......
<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>
<div class="exam-timer">
<%
function gtLtEscape(str) {
return String(str)
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
%>
<%= 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 @@ ...@@ -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 ...@@ -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>
......
<%!
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 @@ ...@@ -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 @@ ...@@ -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 += ( ...@@ -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')),
)
"""
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): ...@@ -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 = (
......
"""
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): ...@@ -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): ...@@ -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 ...@@ -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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment