Commit 83de0d05 by chrisndodge

Merge pull request #111 from edx/rc/2015-08-25

Rc/2015 08 25
parents 01abf5e8 1ba264ba
......@@ -13,6 +13,7 @@ sudo: false
install:
- npm install
- "pip install -r local_requirements.txt"
- "pip install -r requirements.txt"
- "pip install -r test_requirements.txt"
- "pip install coveralls"
......@@ -25,7 +26,3 @@ script:
- pylint edx_proctoring --report=no
after_success: coveralls
branches:
only:
- master
......@@ -4,4 +4,56 @@ edx-proctoring [![Build Status](https://travis-ci.org/edx/edx-proctoring.svg?bra
This is the Exam Proctoring subsystem for the Open edX platform.
This is a work in progress at this point in time.
While technical and developer documentation is forthcoming, here are basic instructions on how to use this
in an Open edX installation.
NOTE: Proctoring will not be available in the Open edX named releases until Dogwood. However, you can use this if you use a copy of edx-platform (master) after 8/20/2015.
In order to use edx-proctoring, you must obtain an account (and secret configuration - see below) with SoftwareSecure, which provide the proctoring review services that edx-proctoring integrates with.
CONFIGURATION:
You will need to turn on the ENABLE_PROCTORED_EXAMS in lms.env.json and cms.env.json FEATURES dictionary:
```
:
"FEATURES": {
:
"ENABLE_PROCTORED_EXAMS": true,
:
}
```
Also in your lms.env.json and cms.env.json file please add the following:
```
"PROCTORING_SETTINGS": {
"LINK_URLS": {
"contact_us": "{add link here}",
"faq": "{add link here}",
"online_proctoring_rules": "{add link here}",
"tech_requirements": "{add link here}"
}
},
```
In your lms.auth.json file, please add the following *secure* information:
```
"PROCTORING_BACKEND_PROVIDER": {
"class": "edx_proctoring.backends.software_secure.SoftwareSecureBackendProvider",
"options": {
"crypto_key": "{add SoftwareSecure crypto key here}",
"exam_register_endpoint": "{add enpoint to SoftwareSecure}",
"exam_sponsor": "{add SoftwareSecure sponsor}",
"organization": "{add SoftwareSecure organization}",
"secret_key": "{add SoftwareSecure secret key}",
"secret_key_id": "{add SoftwareSecure secret key id}",
"software_download_url": "{add SoftwareSecure download url}"
}
},
```
You will need to restart services after these configuration changes for them to take effect.
......@@ -254,10 +254,14 @@ def _check_for_attempt_timeout(attempt):
def _get_exam_attempt(exam_attempt_obj):
"""
Helper method to commonalize the two query patterns
Helper method to commonalize all query patterns
"""
if not exam_attempt_obj:
return None
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
attempt = serialized_attempt_obj.data if exam_attempt_obj else None
attempt = serialized_attempt_obj.data
attempt = _check_for_attempt_timeout(attempt)
return attempt
......@@ -279,6 +283,16 @@ def get_exam_attempt_by_id(attempt_id):
return _get_exam_attempt(exam_attempt_obj)
def get_exam_attempt_by_code(attempt_code):
"""
Signals the beginning of an exam attempt when we only have
an attempt code
"""
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
return _get_exam_attempt(exam_attempt_obj)
def update_exam_attempt(attempt_id, **kwargs):
"""
update exam_attempt
......@@ -297,17 +311,6 @@ def update_exam_attempt(attempt_id, **kwargs):
exam_attempt_obj.save()
def get_exam_attempt_by_code(attempt_code):
"""
Signals the beginning of an exam attempt when we only have
an attempt code
"""
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
return serialized_attempt_obj.data if exam_attempt_obj else None
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
"""
Creates an exam attempt for user_id against exam_id. There should only be
......@@ -498,7 +501,7 @@ def mark_exam_attempt_as_ready(exam_id, user_id):
return update_attempt_status(exam_id, user_id, ProctoredExamStudentAttemptStatus.ready_to_start)
def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, cascade_effects=True):
"""
Internal helper to handle state transitions of attempt status
"""
......@@ -531,18 +534,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
# don't allow state transitions from a completed state to an incomplete state
# if a re-attempt is desired then the current attempt must be deleted
#
in_completed_status = exam_attempt_obj.status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error,
ProctoredExamStudentAttemptStatus.timed_out
]
to_incompleted_status = to_status in [
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
]
in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status(exam_attempt_obj.status)
to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status(to_status)
if in_completed_status and to_incompleted_status:
err_msg = (
......@@ -561,17 +554,11 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
exam_attempt_obj.status = to_status
exam_attempt_obj.save()
# trigger workflow, as needed
credit_service = get_runtime_service('credit')
# see if the status transition this changes credit requirement status
update_credit = to_status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
]
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
# trigger credit workflow, as needed
credit_service = get_runtime_service('credit')
if update_credit:
exam = get_exam_by_id(exam_id)
if to_status == ProctoredExamStudentAttemptStatus.verified:
verification = 'satisfied'
......@@ -600,6 +587,46 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
status=verification
)
if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(to_status):
# some state transitions (namely to a rejected or declined status)
# will mark other exams as declined because once we fail or decline
# one exam all other (un-completed) proctored exams will be likewise
# updated to reflect a declined status
# get all other unattempted exams and mark also as declined
_exams = ProctoredExam.get_all_exams_for_course(
exam_attempt_obj.proctored_exam.course_id,
active_only=True
)
# we just want other exams which are proctored and are not practice
exams = [
exam
for exam in _exams
if (
exam.content_id != exam_attempt_obj.proctored_exam.content_id and
exam.is_proctored and not exam.is_practice_exam
)
]
for exam in exams:
# see if there was an attempt on those other exams already
attempt = get_exam_attempt(exam.id, user_id)
if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']):
# don't touch any completed statuses
# we won't revoke those
continue
if not attempt:
create_exam_attempt(exam.id, user_id, taking_as_proctored=False)
# update any new or existing status to declined
update_attempt_status(
exam.id,
user_id,
ProctoredExamStudentAttemptStatus.declined,
cascade_effects=False
)
if to_status == ProctoredExamStudentAttemptStatus.submitted:
# also mark the exam attempt completed_at timestamp
# after we submit the attempt
......@@ -771,7 +798,7 @@ def _check_eligibility_of_prerequisites(credit_state):
# then make sure those has a 'satisfied' status
for requirement in credit_state['credit_requirement_status']:
if requirement['namespace'] == 'reverification':
if requirement['status'] != 'satisfied':
if requirement['status'] == 'failed':
return False
return True
......
......@@ -341,10 +341,11 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
string = ""
for key in keys:
value = body_json[key]
if str(value) == 'True':
value = 'true'
if str(value) == 'False':
value = 'false'
if isinstance(value, bool):
if value:
value = 'true'
else:
value = 'false'
if isinstance(value, (list, tuple)):
for idx, arr in enumerate(value):
if isinstance(arr, dict):
......@@ -356,7 +357,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
else:
if value != "" and not value:
value = "null"
string += str(prefix) + str(key) + ":" + str(value).encode('utf-8') + '\n'
string += str(prefix) + str(key) + ":" + unicode(value).encode('utf-8') + '\n'
return string
......
# coding=utf-8
"""
Tests for the software_secure module
"""
......@@ -140,13 +141,26 @@ class SoftwareSecureTests(TestCase):
Tests to make sure we can parse a fullname which does not have any spaces in it
"""
def mock_profile_service(user_id): # pylint: disable=unused-argument
"""
Mocked out Profile callback endpoint
"""
return {'name': 'Bono'}
set_runtime_service('credit', MockCreditService())
exam_id = create_exam(
course_id='foo/bar/baz',
content_id='content',
exam_name='Sample Exam',
time_limit_mins=10,
is_proctored=True
)
with HTTMock(mock_response_content):
attempt_id = create_exam_attempt(exam_id, self.user.id, taking_as_proctored=True)
self.assertIsNotNone(attempt_id)
def test_unicode_attempt(self):
"""
Tests to make sure we can handle an attempt when a user's fullname has unicode characters in it
"""
set_runtime_service('profile', mock_profile_service)
set_runtime_service('credit', MockCreditService(profile_fullname=u'अआईउऊऋऌ अआईउऊऋऌ'))
exam_id = create_exam(
course_id='foo/bar/baz',
......
......@@ -71,11 +71,14 @@ class ProctoredExam(TimeStampedModel):
return proctored_exam
@classmethod
def get_all_exams_for_course(cls, course_id):
def get_all_exams_for_course(cls, course_id, active_only=False):
"""
Returns all exams for a give course
"""
return cls.objects.filter(course_id=course_id)
result = cls.objects.filter(course_id=course_id)
if active_only:
result = result.filter(is_active=True)
return result
class ProctoredExamStudentAttemptStatus(object):
......@@ -131,6 +134,52 @@ class ProctoredExamStudentAttemptStatus(object):
# the exam is believed to be in error
error = 'error'
@classmethod
def is_completed_status(cls, status):
"""
Returns a boolean if the passed in status is in a "completed" state, meaning
that it cannot go backwards in state
"""
return status in [
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.timed_out,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.error
]
@classmethod
def is_incomplete_status(cls, status):
"""
Returns a boolean if the passed in status is in an "incomplete" state.
"""
return status in [
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
]
@classmethod
def needs_credit_status_update(cls, to_status):
"""
Returns a boolean if the passed in to_status calls for an update to the credit requirement status.
"""
return to_status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
]
@classmethod
def is_a_cascadable_failure(cls, to_status):
"""
Returns a boolean if the passed in to_status has a failure that needs to be cascaded
to other attempts.
"""
return to_status in [
ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined
]
class ProctoredExamStudentAttemptManager(models.Manager):
"""
......
......@@ -7,7 +7,7 @@ var edx = edx || {};
edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {};
edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = Backbone.View.extend({
initialize: function (options) {
initialize: function () {
this.collection = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceCollection();
this.proctoredExamCollection = new edx.instructor_dashboard.proctoring.ProctoredExamCollection();
/* unfortunately we have to make some assumptions about what is being set up in HTML */
......@@ -140,4 +140,5 @@ var edx = edx || {};
event.preventDefault();
}
});
this.edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView;
}).call(this, Backbone, $, _);
......@@ -114,12 +114,31 @@ var edx = edx || {};
},
render: function () {
if (this.template !== null) {
var data_json = this.collection.toJSON()[0];
// calculate which pages ranges to display
// show no more than 5 pages at the same time
var start_page = data_json.pagination_info.current_page - 2;
if (start_page < 1) {
start_page = 1;
}
var end_page = start_page + 4;
if (end_page > data_json.pagination_info.total_pages) {
end_page = data_json.pagination_info.total_pages;
}
var data = {
proctored_exam_attempts: this.collection.toJSON()[0].proctored_exam_attempts,
pagination_info: this.collection.toJSON()[0].pagination_info,
attempt_url: this.collection.toJSON()[0].attempt_url,
proctored_exam_attempts: data_json.proctored_exam_attempts,
pagination_info: data_json.pagination_info,
attempt_url: data_json.attempt_url,
inSearchMode: this.inSearchMode,
searchText: this.searchText
searchText: this.searchText,
start_page: start_page,
end_page: end_page
};
_.extend(data, viewHelper);
var html = this.template(data);
......
......@@ -85,12 +85,12 @@ var edx = edx || {};
action: 'stop'
},
success: function() {
// Reloading page will reflect the new state of the attempt
location.reload();
// change the location of the page to the active exam page
// which will reflect the new state of the attempt
location.href = self.model.get('exam_url_path');
}
});
});
//$('.proctored-exam-action-stop').css('cursor', 'pointer');
}
}
return this;
......
describe('ProctoredExamAllowanceView', function () {
var html = '';
var expectedProctoredAllowanceJson = [
{
created: "2015-08-10T09:15:45Z",
id: 1,
modified: "2015-08-10T09:15:45Z",
key: "Additional time (minutes)",
value: "1",
proctored_exam: {
content_id: "i4x://edX/DemoX/sequential/9f5e9b018a244ea38e5d157e0019e60c",
course_id: "edX/DemoX/Demo_Course",
exam_name: "Test Exam",
external_id: null,
id: 17,
is_active: true,
is_practice_exam: false,
is_proctored: true,
time_limit_mins: 1
},
user: {
username: 'testuser1',
email: 'testuser1@test.com'
}
}
];
beforeEach(function () {
html = '<span class="tip">' +
'<%- gettext("Allowances") %>' +
'<span> <a id="add-allowance" href="#" class="add blue-button">+' +
'<%- gettext("Add Allowance") %>' +
'</a> </span> </span>' +
'<% var is_allowances = proctored_exam_allowances.length !== 0 %>' +
'<% if (is_allowances) { %>'+
'<div class="wrapper-content wrapper"> <section class="content"> <table class="allowance-table">' +
'<thead><tr class="allowance-headings">' +
'<th class="exam-name">Exam Name</th>' +
'<th class="username">Username</th>' +
'<th class="email">Email</th>' +
'<th class="allowance-name">Allowance Type</th>' +
'<th class="allowance-value">Allowance Value</th>' +
'<th class="c_action">Actions </th>' +
'</tr></thead>' +
'<tbody>' +
'<% _.each(proctored_exam_allowances, function(proctored_exam_allowance){ %>' +
'<tr class="allowance-items">' +
'<td>' +
'<%- interpolate(gettext(" %(exam_display_name)s "), { exam_display_name: proctored_exam_allowance.proctored_exam.exam_name }, true) %>' +
'</td>' +
'<% if (proctored_exam_allowance.user){ %>' +
'<td>' +
'<%- interpolate(gettext(" %(username)s "), { username: proctored_exam_allowance.user.username }, true) %>' +
'</td>' +
'<td>' +
'<%- interpolate(gettext(" %(email)s "), { email: proctored_exam_allowance.user.email }, true) %>' +
'</td>' +
'<% }else{ %>' +
'<td>N/A</td><td>N/A</td>' +
'<% } %>' +
'<td>' +
'<%- interpolate(gettext(" %(allowance_name)s "), { allowance_name: proctored_exam_allowance.key }, true) %>' +
'</td>' +
'<td>' +
'<%= proctored_exam_allowance.value %>' +
'</td>' +
'<td>' +
'<a data-exam-id="<%= proctored_exam_allowance.proctored_exam.id %>" data-key-name="<%= proctored_exam_allowance.key %>" data-user-id="<%= proctored_exam_allowance.user.id %>"class="remove_allowance" href="#">[x]</a>' +
'</td></tr>' +
'<% }); %>' +
'</tbody></table></section></div>' +
'<% } %>';
this.server = sinon.fakeServer.create();
this.server.autoRespond = true;
setFixtures('<div class="special-allowance-container" data-course-id="test_course_id"></div>');
// load the underscore template response before calling the proctored exam allowance view.
this.server.respondWith("GET", "/static/proctoring/templates/course_allowances.underscore",
[
200,
{"Content-Type": "text/html"},
html
]
);
});
afterEach(function() {
this.server.restore();
});
it("should render the proctored exam allowance view properly", function () {
this.server.respondWith('GET', '/api/edx_proctoring/v1/proctored_exam/test_course_id/allowance',
[
200,
{
"Content-Type": "application/json"
},
JSON.stringify(expectedProctoredAllowanceJson)
]
);
this.proctored_exam_allowance = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView();
this.server.respond();
this.server.respond();
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('testuser1');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('testuser1@test.com');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('Additional time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('Test Exam');
});
//
it("should remove the proctored exam allowance", function () {
this.server.respondWith('GET', '/api/edx_proctoring/v1/proctored_exam/test_course_id/allowance',
[
200,
{
"Content-Type": "application/json"
},
JSON.stringify(expectedProctoredAllowanceJson)
]
);
this.proctored_exam_allowance = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView();
this.server.respond();
this.server.respond();
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('testuser1');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('testuser1@test.com');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('Additional time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('Test Exam');
// delete the proctored exam allowance one by one
this.server.respondWith('DELETE', '/api/edx_proctoring/v1/proctored_exam/allowance',
[
200,
{
"Content-Type": "application/json"
},
JSON.stringify([])
]
);
// again fetch the results after the proctored exam allowance deletion
this.server.respondWith('GET', '/api/edx_proctoring/v1/proctored_exam/test_course_id/allowance',
[
200,
{
"Content-Type": "application/json"
},
JSON.stringify([])
]
);
// trigger the remove allowance event.
var spyEvent = spyOnEvent('.remove_allowance', 'click');
$('.remove_allowance').trigger( "click" );
// process the deleted allowance requests.
this.server.respond();
this.server.respond();
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('testuser1');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('testuser1@test.com');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('Additional time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('Test Exam');
});
});
describe('ProctoredExamView', function () {
describe('ProctoredExamAttemptView', function () {
var html = '';
var deletedProctoredExamAttemptJson = [{
attempt_url: '/api/edx_proctoring/v1/proctored_exam/attempt/course_id/edX/DemoX/Demo_Course',
......@@ -149,18 +149,11 @@ describe('ProctoredExamView', function () {
JSON.stringify(expectedProctoredExamAttemptJson)
]
);
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView();
var callbacks = [sinon.spy(), sinon.spy()];
this.server.respond();
this.server.respond();
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView(
{
el: $('.student-proctored-exam-container'),
template_url: '/static/proctoring/templates/student-proctored-exam-attempts.underscore'
}
);
console.log(this.server.requests); // Logs all requests so far
this.server.respond(); // Process all requests so far
this.server.respond(); // Process all requests so far
expect(this.proctored_exam_attempt_view.$el.find('tr.allowance-items')).toContainHtml('<td> testuser1 </td>');
expect(this.proctored_exam_attempt_view.$el.find('tr.allowance-items').html()).toContain('Normal Exam');
});
......@@ -175,15 +168,7 @@ describe('ProctoredExamView', function () {
JSON.stringify(expectedProctoredExamAttemptJson)
]
);
var callbacks = [sinon.spy(), sinon.spy()];
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView(
{
el: $('.student-proctored-exam-container'),
template_url: '/static/proctoring/templates/student-proctored-exam-attempts.underscore'
}
);
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView();
// Process all requests so far
this.server.respond();
......@@ -242,14 +227,7 @@ describe('ProctoredExamView', function () {
]
);
var callbacks = [sinon.spy(), sinon.spy()];
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView(
{
el: $('.student-proctored-exam-container'),
template_url: '/static/proctoring/templates/student-proctored-exam-attempts.underscore'
}
);
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView();
// Process all requests so far
this.server.respond();
......@@ -293,15 +271,7 @@ describe('ProctoredExamView', function () {
JSON.stringify(expectedProctoredExamAttemptJson)
]
);
var callbacks = [sinon.spy(), sinon.spy()];
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView(
{
el: $('.student-proctored-exam-container'),
template_url: '/static/proctoring/templates/student-proctored-exam-attempts.underscore'
}
);
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView();
// Process all requests so far
this.server.respond();
......
......@@ -34,7 +34,7 @@
</a>
</li>
<% }%>
<% for(var n = 1; n <= pagination_info.total_pages; n++) { %>
<% for(var n = start_page; n <= end_page; n++) { %>
<li>
<a class="target-link <% if (pagination_info.current_page == n){ %> active <% } %>"
data-target-url="
......
# pylint: disable=too-many-lines
# pylint: disable=too-many-lines, invalid-name
"""
All tests for the models.py
......@@ -34,7 +34,8 @@ from edx_proctoring.api import (
mark_exam_attempt_as_ready,
update_attempt_status,
get_attempt_status_summary,
update_exam_attempt
update_exam_attempt,
_check_for_attempt_timeout
)
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists,
......@@ -384,6 +385,18 @@ class ProctoredExamApiTests(LoggedInTestCase):
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['allowed_time_limit_mins'], self.default_time_limit + allowed_extra_time)
def test_no_existing_attempt(self):
"""
Make sure we get back a None when calling get_exam_attempt_by_id() with a non existing attempt
"""
self.assertIsNone(get_exam_attempt_by_id(0))
def test_check_for_attempt_timeout_with_none(self):
"""
Make sure that we can safely pass in a None into _check_for_attempt_timeout
"""
self.assertIsNone(_check_for_attempt_timeout(None))
def test_recreate_an_exam_attempt(self):
"""
Start an exam attempt that has already been created.
......@@ -650,14 +663,14 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertIsNone(rendered_response)
@ddt.data(
('reverification', None, False, True, ProctoredExamStudentAttemptStatus.declined),
('reverification', 'failed', False, False, ProctoredExamStudentAttemptStatus.declined),
('reverification', 'satisfied', True, True, None),
('grade', 'failed', True, False, None)
('reverification', None, True, True, False),
('reverification', 'failed', False, False, True),
('reverification', 'satisfied', True, True, False),
('grade', 'failed', True, False, False)
)
@ddt.unpack
def test_prereq_scarios(self, namespace, req_status, show_proctored,
pre_create_attempt, mark_as_declined):
def test_prereq_scenarios(self, namespace, req_status, show_proctored,
pre_create_attempt, mark_as_declined):
"""
This test asserts that proctoring will not be displayed under the following
conditions:
......@@ -1083,6 +1096,110 @@ class ProctoredExamApiTests(LoggedInTestCase):
)
@ddt.data(
(
ProctoredExamStudentAttemptStatus.declined,
False,
None,
ProctoredExamStudentAttemptStatus.declined
),
(
ProctoredExamStudentAttemptStatus.rejected,
False,
None,
ProctoredExamStudentAttemptStatus.declined
),
(
ProctoredExamStudentAttemptStatus.rejected,
True,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.declined
),
(
ProctoredExamStudentAttemptStatus.rejected,
True,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.verified
),
(
ProctoredExamStudentAttemptStatus.declined,
True,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.submitted
),
)
@ddt.unpack
def test_cascading(self, to_status, create_attempt, second_attempt_status, expected_second_status):
"""
Make sure that when we decline/reject one attempt all other exams in the course
are auto marked as declined
"""
# create other exams in course
second_exam_id = create_exam(
course_id=self.course_id,
content_id="2nd exam",
exam_name="2nd exam",
time_limit_mins=self.default_time_limit,
is_practice_exam=False,
is_proctored=True
)
practice_exam_id = create_exam(
course_id=self.course_id,
content_id="practice",
exam_name="practice",
time_limit_mins=self.default_time_limit,
is_practice_exam=True,
is_proctored=True
)
timed_exam_id = create_exam(
course_id=self.course_id,
content_id="timed",
exam_name="timed",
time_limit_mins=self.default_time_limit,
is_practice_exam=False,
is_proctored=False
)
inactive_exam_id = create_exam(
course_id=self.course_id,
content_id="inactive",
exam_name="inactive",
time_limit_mins=self.default_time_limit,
is_practice_exam=False,
is_proctored=True,
is_active=False
)
if create_attempt:
create_exam_attempt(second_exam_id, self.user_id, taking_as_proctored=False)
if second_attempt_status:
update_attempt_status(second_exam_id, self.user_id, second_attempt_status)
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
to_status
)
# make sure we reamain in the right status
read_back = get_exam_attempt(exam_attempt.proctored_exam_id, self.user.id)
self.assertEqual(read_back['status'], to_status)
# make sure an attempt was made for second_exam
second_exam_attempt = get_exam_attempt(second_exam_id, self.user_id)
self.assertIsNotNone(second_exam_attempt)
self.assertEqual(second_exam_attempt['status'], expected_second_status)
# no auto-generated attempts for practice and timed exams
self.assertIsNone(get_exam_attempt(practice_exam_id, self.user_id))
self.assertIsNone(get_exam_attempt(timed_exam_id, self.user_id))
self.assertIsNone(get_exam_attempt(inactive_exam_id, self.user_id))
@ddt.data(
(ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created),
(ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.ready_to_start),
......
......@@ -142,3 +142,27 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
attempt_history = ProctoredExamStudentAttemptHistory.objects.filter(user_id=1)
self.assertEqual(len(attempt_history), 1)
def test_get_exam_attempts(self):
"""
Test to get all the exam attempts for a course
"""
# Create an exam.
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
# create number of exam attempts
for i in range(90):
ProctoredExamStudentAttempt.create_exam_attempt(
proctored_exam.id, i, 'test_name{0}'.format(i), i + 1,
'test_attempt_code{0}'.format(i), True, False, 'test_external_id{0}'.format(i)
)
with self.assertNumQueries(1):
exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts('a/b/c')
self.assertEqual(len(exam_attempts), 90)
......@@ -17,13 +17,13 @@ class MockCreditService(object):
Simple mock of the Credit Service
"""
def __init__(self, enrollment_mode='verified'):
def __init__(self, enrollment_mode='verified', profile_fullname='Wolfgang von Strucker'):
"""
Initializer
"""
self.status = {
'enrollment_mode': enrollment_mode,
'profile_fullname': 'Wolfgang von Strucker',
'profile_fullname': profile_fullname,
'credit_requirement_status': []
}
......
......@@ -557,6 +557,68 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'error')
def test_attempt_callback_timeout(self):
"""
Ensures that the polling from the client will cause the
server to transition to timed_out if the user runs out of time
"""
# Create an exam.
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
attempt_data = {
'exam_id': proctored_exam.id,
'external_id': proctored_exam.external_id,
'start_clock': True,
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
attempt_id = response_data['exam_attempt_id']
self.assertEqual(attempt_id, 1)
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt', args=[attempt_id])
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'started')
attempt_code = response_data['attempt_code']
# test the polling callback point
response = self.client.get(
reverse(
'edx_proctoring.anonymous.proctoring_poll_status',
args=[attempt_code]
)
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'started')
# set time to be in future
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=180)
with freeze_time(reset_time):
# Now the callback should transition us away from started
response = self.client.get(
reverse(
'edx_proctoring.anonymous.proctoring_poll_status',
args=[attempt_code]
)
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'submitted')
def test_remove_attempt(self):
"""
Confirms that an attempt can be removed
......@@ -793,20 +855,23 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
)
attempt_data = {
'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
url = reverse('edx_proctoring.proctored_exam.attempt', kwargs={'course_id': proctored_exam.course_id})
url = reverse('edx_proctoring.proctored_exam.attempts.course', kwargs={'course_id': proctored_exam.course_id})
self.assertEqual(response.status_code, 200)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 1)
attempt = response_data['proctored_exam_attempts'][0]
self.assertEqual(attempt['proctored_exam']['id'], proctored_exam.id)
self.assertEqual(attempt['user']['id'], self.user.id)
url = '{url}?page={invalid_page_no}'.format(url=url, invalid_page_no=9999)
# url with the invalid page # still gives us the first page result.
response = self.client.get(url)
......@@ -835,7 +900,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
url = reverse('edx_proctoring.proctored_exam.attempt', kwargs={'course_id': proctored_exam.course_id})
url = reverse('edx_proctoring.proctored_exam.attempts.course', kwargs={'course_id': proctored_exam.course_id})
self.user.is_staff = False
self.user.save()
......@@ -878,7 +943,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
self.client.login_user(self.user)
response = self.client.get(
reverse(
'edx_proctoring.proctored_exam.attempt.search',
'edx_proctoring.proctored_exam.attempts.search',
kwargs={
'course_id': proctored_exam.course_id,
'search_by': 'tester'
......@@ -889,6 +954,50 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 2)
attempt = response_data['proctored_exam_attempts'][0]
self.assertEqual(attempt['proctored_exam']['id'], proctored_exam.id)
self.assertEqual(attempt['user']['id'], self.second_user.id)
attempt = response_data['proctored_exam_attempts'][1]
self.assertEqual(attempt['proctored_exam']['id'], proctored_exam.id)
self.assertEqual(attempt['user']['id'], self.user.id)
def test_paginated_exam_attempts(self):
"""
Test to get the paginated exam attempts in a course.
"""
# Create an exam.
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
# create number of exam attempts
for i in range(90):
ProctoredExamStudentAttempt.create_exam_attempt(
proctored_exam.id, i, 'test_name{0}'.format(i), i + 1,
'test_attempt_code{0}'.format(i), True, False, 'test_external_id{0}'.format(i)
)
self.client.login_user(self.user)
response = self.client.get(
reverse(
'edx_proctoring.proctored_exam.attempts.course',
kwargs={
'course_id': proctored_exam.course_id
}
)
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 25)
self.assertTrue(response_data['pagination_info']['has_next'])
self.assertEqual(response_data['pagination_info']['total_pages'], 4)
self.assertEqual(response_data['pagination_info']['current_page'], 1)
def test_stop_others_attempt(self):
"""
......
......@@ -30,10 +30,8 @@ class TestClient(Client):
# Create a fake request to store login details.
request = HttpRequest()
if self.session:
request.session = self.session
else:
request.session = engine.SessionStore()
request.session = engine.SessionStore()
login(request, user)
# Set the cookie to represent the session.
......
......@@ -37,14 +37,14 @@ urlpatterns = patterns( # pylint: disable=invalid-name
),
url(
r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}$'.format(settings.COURSE_ID_PATTERN),
views.StudentProctoredExamAttemptCollection.as_view(),
name='edx_proctoring.proctored_exam.attempt'
views.StudentProctoredExamAttemptsByCourse.as_view(),
name='edx_proctoring.proctored_exam.attempts.course'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}/search/(?P<search_by>.+)$'.format(
settings.COURSE_ID_PATTERN),
views.StudentProctoredExamAttemptCollection.as_view(),
name='edx_proctoring.proctored_exam.attempt.search'
views.StudentProctoredExamAttemptsByCourse.as_view(),
name='edx_proctoring.proctored_exam.attempts.search'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt$',
......
......@@ -27,9 +27,7 @@ from edx_proctoring.api import (
get_allowances_for_course,
get_all_exams_for_course,
get_exam_attempt_by_id,
get_all_exam_attempts,
remove_exam_attempt,
get_filtered_exam_attempts,
update_attempt_status
)
from edx_proctoring.exceptions import (
......@@ -39,8 +37,8 @@ from edx_proctoring.exceptions import (
ProctoredExamPermissionDenied,
StudentExamAttemptDoesNotExistsException,
)
from edx_proctoring.serializers import ProctoredExamSerializer
from edx_proctoring.models import ProctoredExamStudentAttemptStatus
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt
from .utils import AuthenticatedAPIView
......@@ -438,53 +436,10 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
return the status of the exam attempt
"""
def get(self, request, course_id=None, search_by=None): # pylint: disable=unused-argument
def get(self, request): # pylint: disable=unused-argument
"""
HTTP GET Handler. Returns the status of the exam attempt.
"""
if course_id is not None:
#
# This code path is only for authenticated global staff users
#
if not request.user.is_staff:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={"detail": "Must be a Staff User to Perform this request."}
)
if search_by is not None:
exam_attempts = get_filtered_exam_attempts(course_id, search_by)
attempt_url = reverse('edx_proctoring.proctored_exam.attempt.search', args=[course_id, search_by])
else:
exam_attempts = get_all_exam_attempts(course_id)
attempt_url = reverse('edx_proctoring.proctored_exam.attempt', args=[course_id])
paginator = Paginator(exam_attempts, ATTEMPTS_PER_PAGE)
page = request.GET.get('page')
try:
exam_attempts_page = paginator.page(page)
except PageNotAnInteger:
# If page is not an integer, deliver first page.
exam_attempts_page = paginator.page(1)
except EmptyPage:
# If page is out of range (e.g. 9999), deliver last page of results.
exam_attempts_page = paginator.page(paginator.num_pages)
data = {
'proctored_exam_attempts': exam_attempts_page.object_list,
'pagination_info': {
'has_previous': exam_attempts_page.has_previous(),
'has_next': exam_attempts_page.has_next(),
'current_page': exam_attempts_page.number,
'total_pages': exam_attempts_page.paginator.num_pages,
},
'attempt_url': attempt_url
}
return Response(
data=data,
status=status.HTTP_200_OK
)
exams = get_active_exams_for_user(request.user.id)
......@@ -583,6 +538,57 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
)
class StudentProctoredExamAttemptsByCourse(AuthenticatedAPIView):
"""
This endpoint is called by the Instructor Dashboard to get
paginated attempts in a course
A search parameter is optional
"""
@method_decorator(require_staff)
def get(self, request, course_id, search_by=None): # pylint: disable=unused-argument
"""
HTTP GET Handler. Returns the status of the exam attempt.
"""
if search_by is not None:
exam_attempts = ProctoredExamStudentAttempt.objects.get_filtered_exam_attempts(course_id, search_by)
attempt_url = reverse('edx_proctoring.proctored_exam.attempts.search', args=[course_id, search_by])
else:
exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts(course_id)
attempt_url = reverse('edx_proctoring.proctored_exam.attempts.course', args=[course_id])
paginator = Paginator(exam_attempts, ATTEMPTS_PER_PAGE)
page = request.GET.get('page')
try:
exam_attempts_page = paginator.page(page)
except PageNotAnInteger:
# If page is not an integer, deliver first page.
exam_attempts_page = paginator.page(1)
except EmptyPage:
# If page is out of range (e.g. 9999), deliver last page of results.
exam_attempts_page = paginator.page(paginator.num_pages)
data = {
'proctored_exam_attempts': [
ProctoredExamStudentAttemptSerializer(attempt).data for
attempt in exam_attempts_page.object_list
],
'pagination_info': {
'has_previous': exam_attempts_page.has_previous(),
'has_next': exam_attempts_page.has_next(),
'current_page': exam_attempts_page.number,
'total_pages': exam_attempts_page.paginator.num_pages,
},
'attempt_url': attempt_url
}
return Response(
data=data,
status=status.HTTP_200_OK
)
class ExamAllowanceView(AuthenticatedAPIView):
"""
Endpoint for the Exam Allowance
......
# Django/Framework Packages
django>=1.4.12,<=1.4.22
django-model-utils==1.4.0
South>=0.7.6
djangorestframework>=2.3.5,<=2.3.14
pytz>=2012h
pycrypto>=2.6
# Django/Framework Packages
django>=1.4.12,<=1.4.21
django-model-utils==1.4.0
South>=0.7.6
djangorestframework>=2.3.5,<=2.3.14
pytz>=2012h
pycrypto>=2.6
# Third Party
-e git+https://github.com/un33k/django-ipware.git@42cb1bb1dc680a60c6452e8bb2b843c2a0382c90#egg=django-ipware
......@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths):
setup(
name='edx-proctoring',
version='0.6.2',
version='0.7.2',
description='Proctoring subsystem for Open edX',
long_description=open('README.md').read(),
author='edX',
......
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