Commit 30f75ba7 by chrisndodge

Merge pull request #176 from edx/rc/2015-10-01

2015-10-01 Release Candidate
parents 79e94d4b 8ec0f3f8
......@@ -5,3 +5,4 @@ omit =
**/__init__.py
**/migrations/*
**/tests/*
edx_proctoring/admin.py
"""
Django Admin pages
"""
# pylint: disable=no-self-argument, no-member
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from django import forms
from edx_proctoring.models import (
ProctoredExamReviewPolicy,
ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureReviewHistory,
)
from edx_proctoring.utils import locate_attempt_by_attempt_code
from edx_proctoring.backends import get_backend_provider
class ProctoredExamReviewPolicyAdmin(admin.ModelAdmin):
"""
The admin panel for Review Policies
"""
readonly_fields = ['set_by_user']
def course_id(obj):
"""
return course_id of related model
"""
return obj.proctored_exam.course_id
def exam_name(obj):
"""
return exam name of related model
"""
return obj.proctored_exam.exam_name
list_display = [
course_id,
exam_name,
]
list_select_related = True
search_fields = ['proctored_exam__course_id', 'proctored_exam__exam_name']
def save_model(self, request, obj, form, change):
"""
Override callback so that we can inject the user_id that made the change
"""
obj.set_by_user = request.user
obj.save()
class ProctoredExamSoftwareSecureReviewForm(forms.ModelForm):
"""Admin Form to display for reading/updating a Review"""
class Meta(object): # pylint: disable=missing-docstring
model = ProctoredExamSoftwareSecureReview
REVIEW_STATUS_CHOICES = [
('Clean', 'Clean'),
('Rules Violation', 'Rules Violation'),
('Suspicious', 'Suspicious'),
('Not Reviewed', 'Not Reviewed'),
]
review_status = forms.ChoiceField(choices=REVIEW_STATUS_CHOICES)
video_url = forms.URLField()
raw_data = forms.CharField(widget=forms.Textarea, label='Reviewer Notes')
def video_url_for_review(obj):
"""Return hyperlink to review video url"""
return (
'<a href="{video_url}" target="_blank">{video_url}</a>'.format(video_url=obj.video_url)
)
video_url_for_review.allow_tags = True
class ReviewListFilter(admin.SimpleListFilter):
"""
Quick filter to allow admins to see which reviews have not been reviewed internally
"""
title = _('internally reviewed')
parameter_name = 'reviewed_by'
def lookups(self, request, model_admin):
"""
List of values to allow admin to select
"""
return (
('all_unreviewed', _('All Unreviewed')),
('all_unreviewed_failures', _('All Unreviewed Failures')),
)
def queryset(self, request, queryset):
"""
Return the filtered queryset
"""
if self.value() == 'all_unreviewed':
return queryset.filter(reviewed_by__isnull=True)
elif self.value() == 'all_unreviewed_failures':
return queryset.filter(reviewed_by__isnull=True, review_status='Suspicious')
else:
return queryset
class ProctoredExamSoftwareSecureReviewAdmin(admin.ModelAdmin):
"""
The admin panel for SoftwareSecure Review records
"""
readonly_fields = [video_url_for_review, 'attempt_code', 'exam', 'student', 'reviewed_by', 'modified']
list_filter = ['review_status', 'exam__course_id', 'exam__exam_name', ReviewListFilter]
list_select_related = True
search_fields = ['student__username', 'attempt_code']
form = ProctoredExamSoftwareSecureReviewForm
def _get_exam_from_attempt_code(self, code):
"""Get exam from attempt code. Note that the attempt code could be an archived one"""
attempt = locate_attempt_by_attempt_code(code)
return attempt.proctored_exam if attempt else None
def course_id_for_review(self, obj):
"""Return course_id associated with review"""
if obj.exam:
return obj.exam.course_id
else:
exam = self._get_exam_from_attempt_code(obj.attempt_code)
return exam.exam_name if exam else '(none)'
def exam_name_for_review(self, obj):
"""Return course_id associated with review"""
if obj.exam:
return obj.exam.exam_name
else:
exam = self._get_exam_from_attempt_code(obj.attempt_code)
return exam.exam_name if exam else '(none)'
def student_username_for_review(self, obj):
"""Return username of student who took the test"""
if obj.student:
return obj.student.username
else:
attempt = locate_attempt_by_attempt_code(obj.attempt_code)
return attempt.user.username if attempt else '(None)'
list_display = [
'course_id_for_review',
'exam_name_for_review',
'student_username_for_review',
'attempt_code',
'modified',
'reviewed_by',
'review_status'
]
def has_add_permission(self, request):
"""Don't allow adds"""
return False
def has_delete_permission(self, request, obj=None):
"""Don't allow deletes"""
return False
def save_model(self, request, review, form, change):
"""
Override callback so that we can inject the user_id that made the change
"""
review.reviewed_by = request.user
review.save()
# call the review saved and since it's coming from
# the Django admin will we accept failures
get_backend_provider().on_review_saved(review, allow_status_update_on_fail=True)
def get_form(self, request, obj=None, **kwargs):
form = super(ProctoredExamSoftwareSecureReviewAdmin, self).get_form(request, obj, **kwargs)
del form.base_fields['video_url']
return form
class ProctoredExamSoftwareSecureReviewHistoryAdmin(ProctoredExamSoftwareSecureReviewAdmin):
"""
The admin panel for SoftwareSecure Review records
"""
readonly_fields = [
video_url_for_review,
'review_status',
'raw_data',
'attempt_code',
'exam',
'student',
'reviewed_by',
'modified',
]
def save_model(self, request, review, form, change):
"""
History can't be updated
"""
return
admin.site.register(ProctoredExamReviewPolicy, ProctoredExamReviewPolicyAdmin)
admin.site.register(ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReviewAdmin)
admin.site.register(ProctoredExamSoftwareSecureReviewHistory, ProctoredExamSoftwareSecureReviewHistoryAdmin)
......@@ -31,6 +31,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus,
ProctoredExamReviewPolicy,
)
from edx_proctoring.serializers import (
ProctoredExamSerializer,
......@@ -346,19 +347,16 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
allowed_time_limit_mins = exam['time_limit_mins']
# add in the allowed additional time
allowance = ProctoredExamStudentAllowance.get_allowance_for_user(
exam_id,
user_id,
"Additional time (minutes)"
)
if allowance:
allowance_extra_mins = int(allowance.value)
allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam_id, user_id)
if allowance_extra_mins:
allowed_time_limit_mins += allowance_extra_mins
attempt_code = unicode(uuid.uuid4()).upper()
external_id = None
review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id)
if taking_as_proctored:
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
callback_url = '{scheme}://{hostname}{path}'.format(
......@@ -378,16 +376,33 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
credit_state = credit_service.get_credit_state(user_id, exam['course_id'])
full_name = credit_state['profile_fullname']
context = {
'time_limit_mins': allowed_time_limit_mins,
'attempt_code': attempt_code,
'is_sample_attempt': exam['is_practice_exam'],
'callback_url': callback_url,
'full_name': full_name,
}
# see if there is an exam review policy for this exam
# if so, then pass it into the provider
if review_policy:
context.update({
'review_policy': review_policy.review_policy
})
# see if there is a review policy exception for this *user*
# exceptions are granted on a individual basis as an
# allowance
if review_policy_exception:
context.update({
'review_policy_exception': review_policy_exception
})
# now call into the backend provider to register exam attempt
external_id = get_backend_provider().register_exam_attempt(
exam,
context={
'time_limit_mins': allowed_time_limit_mins,
'attempt_code': attempt_code,
'is_sample_attempt': exam['is_practice_exam'],
'callback_url': callback_url,
'full_name': full_name,
}
context=context,
)
attempt = ProctoredExamStudentAttempt.create_exam_attempt(
......@@ -398,7 +413,8 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
attempt_code,
taking_as_proctored,
exam['is_practice_exam'],
external_id
external_id,
review_policy_id=review_policy.id if review_policy else None,
)
log_msg = (
......@@ -920,17 +936,17 @@ def _check_eligibility_of_prerequisites(credit_state):
STATUS_SUMMARY_MAP = {
'_default': {
'short_description': _('Taking As Proctored Exam'),
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.eligible: {
'short_description': _('Proctored Option Available'),
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.declined: {
'short_description': _('Taking As Open Exam'),
'suggested_icon': 'fa-unlock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.submitted: {
......@@ -959,7 +975,7 @@ STATUS_SUMMARY_MAP = {
PRACTICE_STATUS_SUMMARY_MAP = {
'_default': {
'short_description': _('Ungraded Practice Exam'),
'suggested_icon': 'fa-lock',
'suggested_icon': '',
'in_completed_state': False
},
ProctoredExamStudentAttemptStatus.submitted: {
......
......@@ -11,7 +11,7 @@ from django.core.exceptions import ImproperlyConfigured
_BACKEND_PROVIDER = None
def get_backend_provider():
def get_backend_provider(emphemeral=False):
"""
Returns an instance of the configured backend provider that is configured
via the settings file
......@@ -19,7 +19,8 @@ def get_backend_provider():
global _BACKEND_PROVIDER # pylint: disable=global-statement
if not _BACKEND_PROVIDER:
provider = _BACKEND_PROVIDER
if not _BACKEND_PROVIDER or emphemeral:
config = getattr(settings, 'PROCTORING_BACKEND_PROVIDER')
if not config:
raise ImproperlyConfigured("Settings not configured with PROCTORING_BACKEND_PROVIDER!")
......@@ -34,6 +35,9 @@ def get_backend_provider():
module_path, _, name = config['class'].rpartition('.')
class_ = getattr(import_module(module_path), name)
_BACKEND_PROVIDER = class_(**config['options'])
provider = class_(**config['options'])
return _BACKEND_PROVIDER
if not emphemeral:
_BACKEND_PROVIDER = provider
return provider
......@@ -50,3 +50,11 @@ class ProctoringBackendProvider(object):
Called when the reviewing 3rd party service posts back the results
"""
raise NotImplementedError()
@abc.abstractmethod
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow.
"""
raise NotImplementedError()
......@@ -41,3 +41,9 @@ class NullBackendProvider(ProctoringBackendProvider):
"""
Called when the reviewing 3rd party service posts back the results
"""
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow
"""
......@@ -44,6 +44,12 @@ class TestBackendProvider(ProctoringBackendProvider):
Called when the reviewing 3rd party service posts back the results
"""
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow
"""
class PassthroughBackendProvider(ProctoringBackendProvider):
"""
......@@ -92,6 +98,13 @@ class PassthroughBackendProvider(ProctoringBackendProvider):
"""
return super(PassthroughBackendProvider, self).on_review_callback(payload)
def on_review_saved(self, review):
"""
called when a review has been save - either through API or via Django Admin panel
in order to trigger any workflow
"""
return super(PassthroughBackendProvider, self).on_review_saved(review)
class TestBackends(TestCase):
"""
......@@ -120,6 +133,9 @@ class TestBackends(TestCase):
with self.assertRaises(NotImplementedError):
provider.on_review_callback(None)
with self.assertRaises(NotImplementedError):
provider.on_review_saved(None)
def test_null_provider(self):
"""
Assert that the Null provider does nothing
......@@ -132,3 +148,4 @@ class TestBackends(TestCase):
self.assertIsNone(provider.stop_exam_attempt(None, None))
self.assertIsNone(provider.get_software_download_url())
self.assertIsNone(provider.on_review_callback(None))
self.assertIsNone(provider.on_review_saved(None))
......@@ -24,3 +24,20 @@ CONTACT_EMAIL = (
settings.PROCTORING_SETTINGS['CONTACT_EMAIL'] if
'CONTACT_EMAIL' in settings.PROCTORING_SETTINGS else getattr(settings, 'CONTACT_EMAIL', FROM_EMAIL)
)
ALLOW_REVIEW_UPDATES = (
settings.PROCTORING_SETTINGS['ALLOW_REVIEW_UPDATES'] if
'ALLOW_REVIEW_UPDATES' in settings.PROCTORING_SETTINGS else getattr(settings, 'ALLOW_REVIEW_UPDATES', True)
)
DEFAULT_SOFTWARE_SECURE_REVIEW_POLICY = (
settings.PROCTORING_SETTINGS['DEFAULT_REVIEW_POLICY'] if
'DEFAULT_REVIEW_POLICY' in settings.PROCTORING_SETTINGS
else getattr(settings, 'DEFAULT_REVIEW_POLICY', 'Closed Book')
)
REQUIRE_FAILURE_SECOND_REVIEWS = (
settings.PROCTORING_SETTINGS['REQUIRE_FAILURE_SECOND_REVIEWS'] if
'REQUIRE_FAILURE_SECOND_REVIEWS' in settings.PROCTORING_SETTINGS
else getattr(settings, 'REQUIRE_FAILURE_SECOND_REVIEWS', True)
)
......@@ -75,7 +75,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"id", "created", "modified", "user", "started_at", "completed_at",
"external_id", "status", "proctored_exam", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt", "taking_as_proctored", "last_poll_timestamp",
"last_poll_ipaddr"
"last_poll_ipaddr", "review_policy_id"
)
......
......@@ -14,6 +14,7 @@ var edx = edx || {};
this.proctored_exams = options.proctored_exams;
this.proctored_exam_allowance_view = options.proctored_exam_allowance_view;
this.course_id = options.course_id;
this.allowance_types = options.allowance_types;
this.model = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceModel();
_.bindAll(this, "render");
this.loadTemplateData();
......@@ -156,11 +157,9 @@ var edx = edx || {};
},
render: function () {
var allowance_types = ['Additional time (minutes)'];
$(this.el).html(this.template({
proctored_exams: this.proctored_exams,
allowance_types: allowance_types
allowance_types: this.allowance_types
}));
this.$form = {
......
......@@ -8,6 +8,12 @@ var edx = edx || {};
edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = Backbone.View.extend({
initialize: function () {
this.allowance_types = [
['additional_time_granted', gettext('Additional Time (minutes)')],
['review_policy_exception', gettext('Review Policy Exception')]
];
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 */
......@@ -121,6 +127,20 @@ var edx = edx || {};
},
render: function () {
if (this.template !== null) {
var self = this;
this.collection.each(function(item){
var key = item.get('key');
var i
for (i=0; i<self.allowance_types.length; i++) {
if (key === self.allowance_types[i][0]) {
item.set('key_display_name', self.allowance_types[i][1]);
break;
}
}
if (!item.has('key_display_name')) {
item.set('key_display_name', key);
}
});
var html = this.template({proctored_exam_allowances: this.collection.toJSON()});
this.$el.html(html);
}
......@@ -132,7 +152,8 @@ var edx = edx || {};
var add_allowance_view = new edx.instructor_dashboard.proctoring.AddAllowanceView({
course_id: self.course_id,
proctored_exams: self.proctoredExamCollection.toJSON(),
proctored_exam_allowance_view: self
proctored_exam_allowance_view: self,
allowance_types: self.allowance_types
});
}
});
......
......@@ -174,6 +174,7 @@ var edx = edx || {};
if (!confirm(gettext('Are you sure you want to remove this student\'s exam attempt?'))) {
return;
}
$('body').css('cursor', 'wait');
var $target = $(event.currentTarget);
var attemptId = $target.data("attemptId");
......@@ -187,6 +188,7 @@ var edx = edx || {};
success: function () {
// fetch the attempts again.
self.hydrate();
$('body').css('cursor', 'auto');
}
});
}
......
......@@ -9,7 +9,7 @@ describe('ProctoredExamAddAllowanceView', function () {
created: "2015-08-10T09:15:45Z",
id: 1,
modified: "2015-08-10T09:15:45Z",
key: "Additional time (minutes)",
key: "additional_time_granted",
value: "1",
proctored_exam: {
content_id: "i4x://edX/DemoX/sequential/9f5e9b018a244ea38e5d157e0019e60c",
......@@ -59,8 +59,8 @@ describe('ProctoredExamAddAllowanceView', function () {
'<label>Allowance Type</label>' +
'</td><td><select id="allowance_type">' +
'<% _.each(allowance_types, function(allowance_type){ %>' +
'<option value="<%= allowance_type %>">' +
'<%- interpolate(gettext(" %(allowance_type)s "), { allowance_type: allowance_type }, true) %>' +
'<option value="<%= allowance_type[0] %>">' +
'<%= allowance_type[1] %>' +
'</option>' +
'<% }); %>' +
'</select></td></tr><tr><td>' +
......@@ -101,7 +101,7 @@ describe('ProctoredExamAddAllowanceView', function () {
'<td>N/A</td><td>N/A</td>' +
'<% } %>' +
'<td>' +
'<%- interpolate(gettext(" %(allowance_name)s "), { allowance_name: proctored_exam_allowance.key }, true) %>' +
'<%- interpolate(gettext(" %(allowance_name)s "), { allowance_name: proctored_exam_allowance.key_display_name }, true) %>' +
'</td>' +
'<td>' +
'<%= proctored_exam_allowance.value %>' +
......@@ -185,7 +185,7 @@ describe('ProctoredExamAddAllowanceView', function () {
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('Additional Time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('Test Exam');
// add the proctored exam allowance
......@@ -213,7 +213,7 @@ describe('ProctoredExamAddAllowanceView', function () {
//select the form values
$('#proctored_exam').val('Test Exam');
$('#allowance_type').val('Additional time (minutes)');
$('#allowance_type').val('additional_time_granted');
$('#allowance_value').val('1');
$("#user_info").val('testuser1');
......@@ -227,7 +227,7 @@ describe('ProctoredExamAddAllowanceView', function () {
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('Additional Time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('Test Exam');
});
it("should send error when adding proctored exam allowance", function () {
......@@ -254,7 +254,7 @@ describe('ProctoredExamAddAllowanceView', function () {
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('Additional Time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('Test Exam');
// add the proctored exam allowance
......@@ -282,7 +282,7 @@ describe('ProctoredExamAddAllowanceView', function () {
//select the form values
// invalid user_info returns error
$('#proctored_exam').val('Test Exam');
$('#allowance_type').val('Additional time (minutes)');
$('#allowance_type').val('additional_time_granted');
$('#allowance_value').val('2');
$("#user_info").val('testuser112321');
......@@ -299,7 +299,7 @@ describe('ProctoredExamAddAllowanceView', function () {
//select the form values
// empty value returns error
$('#proctored_exam').val('Test Exam');
$('#allowance_type').val('Additional time (minutes)');
$('#allowance_type').val('Additional Time (minutes)');
$('#allowance_value').val('');
$("#user_info").val('testuser1');
......
......@@ -59,7 +59,7 @@ describe('ProctoredExamAllowanceView', function () {
'<td>N/A</td><td>N/A</td>' +
'<% } %>' +
'<td>' +
'<%- interpolate(gettext(" %(allowance_name)s "), { allowance_name: proctored_exam_allowance.key }, true) %>' +
'<%- interpolate(gettext(" %(allowance_name)s "), { allowance_name: proctored_exam_allowance.key_display_name }, true) %>' +
'</td>' +
'<td>' +
'<%= proctored_exam_allowance.value %>' +
......
......@@ -23,8 +23,8 @@
<td>
<select id="allowance_type">
<% _.each(allowance_types, function(allowance_type){ %>
<option value="<%= allowance_type %>">
<%- interpolate(gettext(' %(allowance_type)s '), { allowance_type: allowance_type }, true) %>
<option value="<%= allowance_type[0] %>">
<%= allowance_type[1] %>
</option>
<% }); %>
</select>
......
......@@ -23,23 +23,24 @@
<% _.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) %>
<%- proctored_exam_allowance.proctored_exam.exam_name %>
</td>
<% if (proctored_exam_allowance.user){ %>
<td>
<%- interpolate(gettext(' %(username)s '), { username: proctored_exam_allowance.user.username }, true) %>
<%= proctored_exam_allowance.user.username %>
</td>
<td>
<%- interpolate(gettext(' %(email)s '), { email: proctored_exam_allowance.user.email }, true) %>
<%= proctored_exam_allowance.user.email %>
</td>
<% }else{ %>
<td>N/A</td>
<td>N/A</td>
<% } %>
<td>
<%- interpolate(gettext(' %(allowance_name)s '), { allowance_name: proctored_exam_allowance.key }, true) %>
<%= proctored_exam_allowance.key_display_name %>
</td>
<td><%= proctored_exam_allowance.value %></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 %>"
......
......@@ -54,6 +54,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus,
ProctoredExamReviewPolicy,
)
from .utils import (
......@@ -373,6 +374,24 @@ class ProctoredExamApiTests(LoggedInTestCase):
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id)
self.assertGreater(attempt_id, 0)
def test_attempt_with_review_policy(self):
"""
Create an unstarted exam attempt with a review policy associated with it.
"""
policy = ProctoredExamReviewPolicy.objects.create(
set_by_user_id=self.user_id,
proctored_exam_id=self.proctored_exam_id,
review_policy='Foo Policy'
)
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id)
self.assertGreater(attempt_id, 0)
# make sure we recorded the policy id at the time this was created
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['review_policy_id'], policy.id)
def test_attempt_with_allowance(self):
"""
Create an unstarted exam attempt with additional time.
......@@ -381,7 +400,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
add_allowance_for_user(
self.proctored_exam_id,
self.user.username,
"Additional time (minutes)",
ProctoredExamStudentAllowance.ADDITIONAL_TIME_GRANTED,
str(allowed_extra_time)
)
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id)
......@@ -1297,7 +1316,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Proctored Option Available',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1305,7 +1324,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.declined, {
'status': ProctoredExamStudentAttemptStatus.declined,
'short_description': 'Taking As Open Exam',
'suggested_icon': 'fa-unlock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1345,7 +1364,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.created, {
'status': ProctoredExamStudentAttemptStatus.created,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1353,7 +1372,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_start, {
'status': ProctoredExamStudentAttemptStatus.ready_to_start,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1361,7 +1380,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.started, {
'status': ProctoredExamStudentAttemptStatus.started,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
......@@ -1369,7 +1388,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_submit, {
'status': ProctoredExamStudentAttemptStatus.ready_to_submit,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
)
......@@ -1400,7 +1419,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': '',
'in_completed_state': False
}
),
......@@ -1471,7 +1490,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': '',
'in_completed_state': False
}
),
......@@ -1524,7 +1543,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
expected = {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock',
'suggested_icon': '',
'in_completed_state': False
}
......
"""
All tests for the models.py
"""
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAllowanceHistory, \
ProctoredExamStudentAttempt, ProctoredExamStudentAttemptHistory
from edx_proctoring.models import (
ProctoredExam,
ProctoredExamStudentAllowance,
ProctoredExamStudentAllowanceHistory,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
ProctoredExamReviewPolicy,
ProctoredExamReviewPolicyHistory,
)
from .utils import (
LoggedInTestCase
......@@ -166,3 +173,82 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
with self.assertNumQueries(1):
exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts('a/b/c')
self.assertEqual(len(exam_attempts), 90)
def test_exam_review_policy(self):
"""
Assert correct behavior of the Exam Policy model including archiving of updates and deletes
"""
# 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
)
policy = ProctoredExamReviewPolicy.objects.create(
set_by_user_id=self.user.id,
proctored_exam=proctored_exam,
review_policy='Foo Policy'
)
attempt = ProctoredExamStudentAttempt.create_exam_attempt(
proctored_exam.id,
self.user.id,
'test_name{0}'.format(self.user.id),
self.user.id + 1,
'test_attempt_code{0}'.format(self.user.id),
True,
False,
'test_external_id{0}'.format(self.user.id)
)
attempt.review_policy_id = policy.id
attempt.save()
history = ProctoredExamReviewPolicyHistory.objects.all()
self.assertEqual(len(history), 0)
# now update it
policy.review_policy = 'Updated Foo Policy'
policy.save()
# look in history
history = ProctoredExamReviewPolicyHistory.objects.all()
self.assertEqual(len(history), 1)
previous = history[0]
self.assertEqual(previous.set_by_user_id, self.user.id)
self.assertEqual(previous.proctored_exam_id, proctored_exam.id)
self.assertEqual(previous.original_id, policy.id)
self.assertEqual(previous.review_policy, 'Foo Policy')
# now delete updated one
deleted_id = policy.id
policy.delete()
# look in history
history = ProctoredExamReviewPolicyHistory.objects.all()
self.assertEqual(len(history), 2)
previous = history[0]
self.assertEqual(previous.set_by_user_id, self.user.id)
self.assertEqual(previous.proctored_exam_id, proctored_exam.id)
self.assertEqual(previous.original_id, deleted_id)
self.assertEqual(previous.review_policy, 'Foo Policy')
previous = history[1]
self.assertEqual(previous.set_by_user_id, self.user.id)
self.assertEqual(previous.proctored_exam_id, proctored_exam.id)
self.assertEqual(previous.original_id, deleted_id)
self.assertEqual(previous.review_policy, 'Updated Foo Policy')
# assert that we cannot delete history!
with self.assertRaises(NotImplementedError):
previous.delete()
# now delete attempt, to make sure we preserve the policy_id in the archive table
attempt.delete()
attempts = ProctoredExamStudentAttemptHistory.objects.all()
self.assertEqual(len(attempts), 1)
self.assertEqual(attempts[0].review_policy_id, deleted_id)
......@@ -3,6 +3,7 @@ Helpers for the HTTP APIs
"""
import pytz
import logging
from datetime import datetime, timedelta
from django.utils.translation import ugettext as _
......@@ -10,6 +11,13 @@ from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from edx_proctoring.models import (
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
)
log = logging.getLogger(__name__)
class AuthenticatedAPIView(APIView):
"""
......@@ -82,3 +90,27 @@ def humanized_time(time_in_minutes):
human_time = template.format(num_of_hours=hours, num_of_minutes=minutes)
return human_time
def locate_attempt_by_attempt_code(attempt_code):
"""
Helper method to look up an attempt by attempt_code. This can be either in
the ProctoredExamStudentAttempt *OR* ProctoredExamStudentAttemptHistory tables
we will return a tuple of (attempt, is_archived_attempt)
"""
attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
is_archived_attempt = False
if not attempt_obj:
# try archive table
attempt_obj = ProctoredExamStudentAttemptHistory.get_exam_attempt_by_code(attempt_code)
is_archived_attempt = True
if not attempt_obj:
# still can't find, error out
err_msg = (
'Could not locate attempt_code: {attempt_code}'.format(attempt_code=attempt_code)
)
log.error(err_msg)
return (attempt_obj, is_archived_attempt)
......@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths):
setup(
name='edx-proctoring',
version='0.9.10',
version='0.9.14',
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