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 ...@@ -13,6 +13,7 @@ sudo: false
install: install:
- npm install - npm install
- "pip install -r local_requirements.txt"
- "pip install -r requirements.txt" - "pip install -r requirements.txt"
- "pip install -r test_requirements.txt" - "pip install -r test_requirements.txt"
- "pip install coveralls" - "pip install coveralls"
...@@ -25,7 +26,3 @@ script: ...@@ -25,7 +26,3 @@ script:
- pylint edx_proctoring --report=no - pylint edx_proctoring --report=no
after_success: coveralls after_success: coveralls
branches:
only:
- master
...@@ -4,4 +4,56 @@ edx-proctoring [![Build Status](https://travis-ci.org/edx/edx-proctoring.svg?bra ...@@ -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 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): ...@@ -254,10 +254,14 @@ def _check_for_attempt_timeout(attempt):
def _get_exam_attempt(exam_attempt_obj): 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) 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) attempt = _check_for_attempt_timeout(attempt)
return attempt return attempt
...@@ -279,6 +283,16 @@ def get_exam_attempt_by_id(attempt_id): ...@@ -279,6 +283,16 @@ def get_exam_attempt_by_id(attempt_id):
return _get_exam_attempt(exam_attempt_obj) 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): def update_exam_attempt(attempt_id, **kwargs):
""" """
update exam_attempt update exam_attempt
...@@ -297,17 +311,6 @@ def update_exam_attempt(attempt_id, **kwargs): ...@@ -297,17 +311,6 @@ def update_exam_attempt(attempt_id, **kwargs):
exam_attempt_obj.save() 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): 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 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): ...@@ -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) 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 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): ...@@ -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 # 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 # if a re-attempt is desired then the current attempt must be deleted
# #
in_completed_status = exam_attempt_obj.status in [ in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status(exam_attempt_obj.status)
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected, to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status(to_status)
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
]
if in_completed_status and to_incompleted_status: if in_completed_status and to_incompleted_status:
err_msg = ( err_msg = (
...@@ -561,17 +554,11 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True): ...@@ -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.status = to_status
exam_attempt_obj.save() exam_attempt_obj.save()
# trigger workflow, as needed
credit_service = get_runtime_service('credit')
# see if the status transition this changes credit requirement status # see if the status transition this changes credit requirement status
update_credit = to_status in [ if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected, # trigger credit workflow, as needed
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed, credit_service = get_runtime_service('credit')
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
]
if update_credit:
exam = get_exam_by_id(exam_id) exam = get_exam_by_id(exam_id)
if to_status == ProctoredExamStudentAttemptStatus.verified: if to_status == ProctoredExamStudentAttemptStatus.verified:
verification = 'satisfied' verification = 'satisfied'
...@@ -600,6 +587,46 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True): ...@@ -600,6 +587,46 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
status=verification 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: if to_status == ProctoredExamStudentAttemptStatus.submitted:
# also mark the exam attempt completed_at timestamp # also mark the exam attempt completed_at timestamp
# after we submit the attempt # after we submit the attempt
...@@ -771,7 +798,7 @@ def _check_eligibility_of_prerequisites(credit_state): ...@@ -771,7 +798,7 @@ def _check_eligibility_of_prerequisites(credit_state):
# then make sure those has a 'satisfied' status # then make sure those has a 'satisfied' status
for requirement in credit_state['credit_requirement_status']: for requirement in credit_state['credit_requirement_status']:
if requirement['namespace'] == 'reverification': if requirement['namespace'] == 'reverification':
if requirement['status'] != 'satisfied': if requirement['status'] == 'failed':
return False return False
return True return True
......
...@@ -341,10 +341,11 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -341,10 +341,11 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
string = "" string = ""
for key in keys: for key in keys:
value = body_json[key] value = body_json[key]
if str(value) == 'True': if isinstance(value, bool):
value = 'true' if value:
if str(value) == 'False': value = 'true'
value = 'false' else:
value = 'false'
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
for idx, arr in enumerate(value): for idx, arr in enumerate(value):
if isinstance(arr, dict): if isinstance(arr, dict):
...@@ -356,7 +357,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -356,7 +357,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
else: else:
if value != "" and not value: if value != "" and not value:
value = "null" 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 return string
......
# coding=utf-8
""" """
Tests for the software_secure module Tests for the software_secure module
""" """
...@@ -140,13 +141,26 @@ class SoftwareSecureTests(TestCase): ...@@ -140,13 +141,26 @@ class SoftwareSecureTests(TestCase):
Tests to make sure we can parse a fullname which does not have any spaces in it 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 set_runtime_service('credit', MockCreditService())
"""
Mocked out Profile callback endpoint exam_id = create_exam(
""" course_id='foo/bar/baz',
return {'name': 'Bono'} 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( exam_id = create_exam(
course_id='foo/bar/baz', course_id='foo/bar/baz',
......
...@@ -71,11 +71,14 @@ class ProctoredExam(TimeStampedModel): ...@@ -71,11 +71,14 @@ class ProctoredExam(TimeStampedModel):
return proctored_exam return proctored_exam
@classmethod @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 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): class ProctoredExamStudentAttemptStatus(object):
...@@ -131,6 +134,52 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -131,6 +134,52 @@ class ProctoredExamStudentAttemptStatus(object):
# the exam is believed to be in error # the exam is believed to be in error
error = '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): class ProctoredExamStudentAttemptManager(models.Manager):
""" """
......
...@@ -7,7 +7,7 @@ var edx = edx || {}; ...@@ -7,7 +7,7 @@ var edx = edx || {};
edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {}; edx.instructor_dashboard.proctoring = edx.instructor_dashboard.proctoring || {};
edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = Backbone.View.extend({ edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = Backbone.View.extend({
initialize: function (options) { initialize: function () {
this.collection = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceCollection(); this.collection = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceCollection();
this.proctoredExamCollection = new edx.instructor_dashboard.proctoring.ProctoredExamCollection(); this.proctoredExamCollection = new edx.instructor_dashboard.proctoring.ProctoredExamCollection();
/* unfortunately we have to make some assumptions about what is being set up in HTML */ /* unfortunately we have to make some assumptions about what is being set up in HTML */
...@@ -140,4 +140,5 @@ var edx = edx || {}; ...@@ -140,4 +140,5 @@ var edx = edx || {};
event.preventDefault(); event.preventDefault();
} }
}); });
this.edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView;
}).call(this, Backbone, $, _); }).call(this, Backbone, $, _);
...@@ -114,12 +114,31 @@ var edx = edx || {}; ...@@ -114,12 +114,31 @@ var edx = edx || {};
}, },
render: function () { render: function () {
if (this.template !== null) { 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 = { var data = {
proctored_exam_attempts: this.collection.toJSON()[0].proctored_exam_attempts, proctored_exam_attempts: data_json.proctored_exam_attempts,
pagination_info: this.collection.toJSON()[0].pagination_info, pagination_info: data_json.pagination_info,
attempt_url: this.collection.toJSON()[0].attempt_url, attempt_url: data_json.attempt_url,
inSearchMode: this.inSearchMode, inSearchMode: this.inSearchMode,
searchText: this.searchText searchText: this.searchText,
start_page: start_page,
end_page: end_page
}; };
_.extend(data, viewHelper); _.extend(data, viewHelper);
var html = this.template(data); var html = this.template(data);
......
...@@ -85,12 +85,12 @@ var edx = edx || {}; ...@@ -85,12 +85,12 @@ var edx = edx || {};
action: 'stop' action: 'stop'
}, },
success: function() { success: function() {
// Reloading page will reflect the new state of the attempt // change the location of the page to the active exam page
location.reload(); // 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; 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 html = '';
var deletedProctoredExamAttemptJson = [{ var deletedProctoredExamAttemptJson = [{
attempt_url: '/api/edx_proctoring/v1/proctored_exam/attempt/course_id/edX/DemoX/Demo_Course', attempt_url: '/api/edx_proctoring/v1/proctored_exam/attempt/course_id/edX/DemoX/Demo_Course',
...@@ -149,18 +149,11 @@ describe('ProctoredExamView', function () { ...@@ -149,18 +149,11 @@ describe('ProctoredExamView', function () {
JSON.stringify(expectedProctoredExamAttemptJson) 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')).toContainHtml('<td> testuser1 </td>');
expect(this.proctored_exam_attempt_view.$el.find('tr.allowance-items').html()).toContain('Normal Exam'); expect(this.proctored_exam_attempt_view.$el.find('tr.allowance-items').html()).toContain('Normal Exam');
}); });
...@@ -175,15 +168,7 @@ describe('ProctoredExamView', function () { ...@@ -175,15 +168,7 @@ describe('ProctoredExamView', function () {
JSON.stringify(expectedProctoredExamAttemptJson) JSON.stringify(expectedProctoredExamAttemptJson)
] ]
); );
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView();
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'
}
);
// Process all requests so far // Process all requests so far
this.server.respond(); this.server.respond();
...@@ -242,14 +227,7 @@ describe('ProctoredExamView', function () { ...@@ -242,14 +227,7 @@ describe('ProctoredExamView', function () {
] ]
); );
var callbacks = [sinon.spy(), sinon.spy()]; this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView();
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'
}
);
// Process all requests so far // Process all requests so far
this.server.respond(); this.server.respond();
...@@ -293,15 +271,7 @@ describe('ProctoredExamView', function () { ...@@ -293,15 +271,7 @@ describe('ProctoredExamView', function () {
JSON.stringify(expectedProctoredExamAttemptJson) JSON.stringify(expectedProctoredExamAttemptJson)
] ]
); );
this.proctored_exam_attempt_view = new edx.instructor_dashboard.proctoring.ProctoredExamAttemptView();
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'
}
);
// Process all requests so far // Process all requests so far
this.server.respond(); this.server.respond();
......
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
</a> </a>
</li> </li>
<% }%> <% }%>
<% for(var n = 1; n <= pagination_info.total_pages; n++) { %> <% for(var n = start_page; n <= end_page; n++) { %>
<li> <li>
<a class="target-link <% if (pagination_info.current_page == n){ %> active <% } %>" <a class="target-link <% if (pagination_info.current_page == n){ %> active <% } %>"
data-target-url=" data-target-url="
......
# pylint: disable=too-many-lines # pylint: disable=too-many-lines, invalid-name
""" """
All tests for the models.py All tests for the models.py
...@@ -34,7 +34,8 @@ from edx_proctoring.api import ( ...@@ -34,7 +34,8 @@ from edx_proctoring.api import (
mark_exam_attempt_as_ready, mark_exam_attempt_as_ready,
update_attempt_status, update_attempt_status,
get_attempt_status_summary, get_attempt_status_summary,
update_exam_attempt update_exam_attempt,
_check_for_attempt_timeout
) )
from edx_proctoring.exceptions import ( from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamAlreadyExists,
...@@ -384,6 +385,18 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -384,6 +385,18 @@ class ProctoredExamApiTests(LoggedInTestCase):
attempt = get_exam_attempt_by_id(attempt_id) attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['allowed_time_limit_mins'], self.default_time_limit + allowed_extra_time) 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): def test_recreate_an_exam_attempt(self):
""" """
Start an exam attempt that has already been created. Start an exam attempt that has already been created.
...@@ -650,14 +663,14 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -650,14 +663,14 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertIsNone(rendered_response) self.assertIsNone(rendered_response)
@ddt.data( @ddt.data(
('reverification', None, False, True, ProctoredExamStudentAttemptStatus.declined), ('reverification', None, True, True, False),
('reverification', 'failed', False, False, ProctoredExamStudentAttemptStatus.declined), ('reverification', 'failed', False, False, True),
('reverification', 'satisfied', True, True, None), ('reverification', 'satisfied', True, True, False),
('grade', 'failed', True, False, None) ('grade', 'failed', True, False, False)
) )
@ddt.unpack @ddt.unpack
def test_prereq_scarios(self, namespace, req_status, show_proctored, def test_prereq_scenarios(self, namespace, req_status, show_proctored,
pre_create_attempt, mark_as_declined): pre_create_attempt, mark_as_declined):
""" """
This test asserts that proctoring will not be displayed under the following This test asserts that proctoring will not be displayed under the following
conditions: conditions:
...@@ -1083,6 +1096,110 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1083,6 +1096,110 @@ class ProctoredExamApiTests(LoggedInTestCase):
) )
@ddt.data( @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.declined, ProctoredExamStudentAttemptStatus.eligible),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created), (ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created),
(ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.ready_to_start), (ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.ready_to_start),
......
...@@ -142,3 +142,27 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase): ...@@ -142,3 +142,27 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
attempt_history = ProctoredExamStudentAttemptHistory.objects.filter(user_id=1) attempt_history = ProctoredExamStudentAttemptHistory.objects.filter(user_id=1)
self.assertEqual(len(attempt_history), 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): ...@@ -17,13 +17,13 @@ class MockCreditService(object):
Simple mock of the Credit Service Simple mock of the Credit Service
""" """
def __init__(self, enrollment_mode='verified'): def __init__(self, enrollment_mode='verified', profile_fullname='Wolfgang von Strucker'):
""" """
Initializer Initializer
""" """
self.status = { self.status = {
'enrollment_mode': enrollment_mode, 'enrollment_mode': enrollment_mode,
'profile_fullname': 'Wolfgang von Strucker', 'profile_fullname': profile_fullname,
'credit_requirement_status': [] 'credit_requirement_status': []
} }
......
...@@ -557,6 +557,68 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -557,6 +557,68 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['status'], 'error') 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): def test_remove_attempt(self):
""" """
Confirms that an attempt can be removed Confirms that an attempt can be removed
...@@ -793,20 +855,23 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -793,20 +855,23 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
) )
attempt_data = { attempt_data = {
'exam_id': proctored_exam.id, 'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id 'external_id': proctored_exam.external_id
} }
response = self.client.post( response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'), reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data 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) self.assertEqual(response.status_code, 200)
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 1) 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 = '{url}?page={invalid_page_no}'.format(url=url, invalid_page_no=9999)
# url with the invalid page # still gives us the first page result. # url with the invalid page # still gives us the first page result.
response = self.client.get(url) response = self.client.get(url)
...@@ -835,7 +900,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -835,7 +900,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
reverse('edx_proctoring.proctored_exam.attempt.collection'), reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data 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.is_staff = False
self.user.save() self.user.save()
...@@ -878,7 +943,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -878,7 +943,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
self.client.login_user(self.user) self.client.login_user(self.user)
response = self.client.get( response = self.client.get(
reverse( reverse(
'edx_proctoring.proctored_exam.attempt.search', 'edx_proctoring.proctored_exam.attempts.search',
kwargs={ kwargs={
'course_id': proctored_exam.course_id, 'course_id': proctored_exam.course_id,
'search_by': 'tester' 'search_by': 'tester'
...@@ -889,6 +954,50 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -889,6 +954,50 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(len(response_data['proctored_exam_attempts']), 2) 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): def test_stop_others_attempt(self):
""" """
......
...@@ -30,10 +30,8 @@ class TestClient(Client): ...@@ -30,10 +30,8 @@ class TestClient(Client):
# Create a fake request to store login details. # Create a fake request to store login details.
request = HttpRequest() request = HttpRequest()
if self.session:
request.session = self.session request.session = engine.SessionStore()
else:
request.session = engine.SessionStore()
login(request, user) login(request, user)
# Set the cookie to represent the session. # Set the cookie to represent the session.
......
...@@ -37,14 +37,14 @@ urlpatterns = patterns( # pylint: disable=invalid-name ...@@ -37,14 +37,14 @@ urlpatterns = patterns( # pylint: disable=invalid-name
), ),
url( url(
r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}$'.format(settings.COURSE_ID_PATTERN), r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}$'.format(settings.COURSE_ID_PATTERN),
views.StudentProctoredExamAttemptCollection.as_view(), views.StudentProctoredExamAttemptsByCourse.as_view(),
name='edx_proctoring.proctored_exam.attempt' name='edx_proctoring.proctored_exam.attempts.course'
), ),
url( url(
r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}/search/(?P<search_by>.+)$'.format( r'edx_proctoring/v1/proctored_exam/attempt/course_id/{}/search/(?P<search_by>.+)$'.format(
settings.COURSE_ID_PATTERN), settings.COURSE_ID_PATTERN),
views.StudentProctoredExamAttemptCollection.as_view(), views.StudentProctoredExamAttemptsByCourse.as_view(),
name='edx_proctoring.proctored_exam.attempt.search' name='edx_proctoring.proctored_exam.attempts.search'
), ),
url( url(
r'edx_proctoring/v1/proctored_exam/attempt$', r'edx_proctoring/v1/proctored_exam/attempt$',
......
...@@ -27,9 +27,7 @@ from edx_proctoring.api import ( ...@@ -27,9 +27,7 @@ from edx_proctoring.api import (
get_allowances_for_course, get_allowances_for_course,
get_all_exams_for_course, get_all_exams_for_course,
get_exam_attempt_by_id, get_exam_attempt_by_id,
get_all_exam_attempts,
remove_exam_attempt, remove_exam_attempt,
get_filtered_exam_attempts,
update_attempt_status update_attempt_status
) )
from edx_proctoring.exceptions import ( from edx_proctoring.exceptions import (
...@@ -39,8 +37,8 @@ from edx_proctoring.exceptions import ( ...@@ -39,8 +37,8 @@ from edx_proctoring.exceptions import (
ProctoredExamPermissionDenied, ProctoredExamPermissionDenied,
StudentExamAttemptDoesNotExistsException, StudentExamAttemptDoesNotExistsException,
) )
from edx_proctoring.serializers import ProctoredExamSerializer from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer
from edx_proctoring.models import ProctoredExamStudentAttemptStatus from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt
from .utils import AuthenticatedAPIView from .utils import AuthenticatedAPIView
...@@ -438,53 +436,10 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView): ...@@ -438,53 +436,10 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
return the status of the exam attempt 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. 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) exams = get_active_exams_for_user(request.user.id)
...@@ -583,6 +538,57 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView): ...@@ -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): class ExamAllowanceView(AuthenticatedAPIView):
""" """
Endpoint for the Exam Allowance 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 # Third Party
-e git+https://github.com/un33k/django-ipware.git@42cb1bb1dc680a60c6452e8bb2b843c2a0382c90#egg=django-ipware -e git+https://github.com/un33k/django-ipware.git@42cb1bb1dc680a60c6452e8bb2b843c2a0382c90#egg=django-ipware
...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths): ...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths):
setup( setup(
name='edx-proctoring', name='edx-proctoring',
version='0.6.2', version='0.7.2',
description='Proctoring subsystem for Open edX', description='Proctoring subsystem for Open edX',
long_description=open('README.md').read(), long_description=open('README.md').read(),
author='edX', 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