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 = ...@@ -5,3 +5,4 @@ omit =
**/__init__.py **/__init__.py
**/migrations/* **/migrations/*
**/tests/* **/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 ( ...@@ -31,6 +31,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAllowance, ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt, ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
ProctoredExamReviewPolicy,
) )
from edx_proctoring.serializers import ( from edx_proctoring.serializers import (
ProctoredExamSerializer, ProctoredExamSerializer,
...@@ -346,19 +347,16 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): ...@@ -346,19 +347,16 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
allowed_time_limit_mins = exam['time_limit_mins'] allowed_time_limit_mins = exam['time_limit_mins']
# add in the allowed additional time # add in the allowed additional time
allowance = ProctoredExamStudentAllowance.get_allowance_for_user( allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam_id, user_id)
exam_id, if allowance_extra_mins:
user_id,
"Additional time (minutes)"
)
if allowance:
allowance_extra_mins = int(allowance.value)
allowed_time_limit_mins += allowance_extra_mins allowed_time_limit_mins += allowance_extra_mins
attempt_code = unicode(uuid.uuid4()).upper() attempt_code = unicode(uuid.uuid4()).upper()
external_id = None 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: if taking_as_proctored:
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http' scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
callback_url = '{scheme}://{hostname}{path}'.format( callback_url = '{scheme}://{hostname}{path}'.format(
...@@ -378,16 +376,33 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): ...@@ -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']) credit_state = credit_service.get_credit_state(user_id, exam['course_id'])
full_name = credit_state['profile_fullname'] 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 # now call into the backend provider to register exam attempt
external_id = get_backend_provider().register_exam_attempt( external_id = get_backend_provider().register_exam_attempt(
exam, exam,
context={ context=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,
}
) )
attempt = ProctoredExamStudentAttempt.create_exam_attempt( attempt = ProctoredExamStudentAttempt.create_exam_attempt(
...@@ -398,7 +413,8 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): ...@@ -398,7 +413,8 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
attempt_code, attempt_code,
taking_as_proctored, taking_as_proctored,
exam['is_practice_exam'], exam['is_practice_exam'],
external_id external_id,
review_policy_id=review_policy.id if review_policy else None,
) )
log_msg = ( log_msg = (
...@@ -920,17 +936,17 @@ def _check_eligibility_of_prerequisites(credit_state): ...@@ -920,17 +936,17 @@ def _check_eligibility_of_prerequisites(credit_state):
STATUS_SUMMARY_MAP = { STATUS_SUMMARY_MAP = {
'_default': { '_default': {
'short_description': _('Taking As Proctored Exam'), 'short_description': _('Taking As Proctored Exam'),
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.eligible: { ProctoredExamStudentAttemptStatus.eligible: {
'short_description': _('Proctored Option Available'), 'short_description': _('Proctored Option Available'),
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.declined: { ProctoredExamStudentAttemptStatus.declined: {
'short_description': _('Taking As Open Exam'), 'short_description': _('Taking As Open Exam'),
'suggested_icon': 'fa-unlock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.submitted: { ProctoredExamStudentAttemptStatus.submitted: {
...@@ -959,7 +975,7 @@ STATUS_SUMMARY_MAP = { ...@@ -959,7 +975,7 @@ STATUS_SUMMARY_MAP = {
PRACTICE_STATUS_SUMMARY_MAP = { PRACTICE_STATUS_SUMMARY_MAP = {
'_default': { '_default': {
'short_description': _('Ungraded Practice Exam'), 'short_description': _('Ungraded Practice Exam'),
'suggested_icon': 'fa-lock', 'suggested_icon': '',
'in_completed_state': False 'in_completed_state': False
}, },
ProctoredExamStudentAttemptStatus.submitted: { ProctoredExamStudentAttemptStatus.submitted: {
......
...@@ -11,7 +11,7 @@ from django.core.exceptions import ImproperlyConfigured ...@@ -11,7 +11,7 @@ from django.core.exceptions import ImproperlyConfigured
_BACKEND_PROVIDER = None _BACKEND_PROVIDER = None
def get_backend_provider(): def get_backend_provider(emphemeral=False):
""" """
Returns an instance of the configured backend provider that is configured Returns an instance of the configured backend provider that is configured
via the settings file via the settings file
...@@ -19,7 +19,8 @@ def get_backend_provider(): ...@@ -19,7 +19,8 @@ def get_backend_provider():
global _BACKEND_PROVIDER # pylint: disable=global-statement 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') config = getattr(settings, 'PROCTORING_BACKEND_PROVIDER')
if not config: if not config:
raise ImproperlyConfigured("Settings not configured with PROCTORING_BACKEND_PROVIDER!") raise ImproperlyConfigured("Settings not configured with PROCTORING_BACKEND_PROVIDER!")
...@@ -34,6 +35,9 @@ def get_backend_provider(): ...@@ -34,6 +35,9 @@ def get_backend_provider():
module_path, _, name = config['class'].rpartition('.') module_path, _, name = config['class'].rpartition('.')
class_ = getattr(import_module(module_path), name) 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): ...@@ -50,3 +50,11 @@ class ProctoringBackendProvider(object):
Called when the reviewing 3rd party service posts back the results Called when the reviewing 3rd party service posts back the results
""" """
raise NotImplementedError() 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): ...@@ -41,3 +41,9 @@ class NullBackendProvider(ProctoringBackendProvider):
""" """
Called when the reviewing 3rd party service posts back the results 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): ...@@ -44,6 +44,12 @@ class TestBackendProvider(ProctoringBackendProvider):
Called when the reviewing 3rd party service posts back the results 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): class PassthroughBackendProvider(ProctoringBackendProvider):
""" """
...@@ -92,6 +98,13 @@ class PassthroughBackendProvider(ProctoringBackendProvider): ...@@ -92,6 +98,13 @@ class PassthroughBackendProvider(ProctoringBackendProvider):
""" """
return super(PassthroughBackendProvider, self).on_review_callback(payload) 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): class TestBackends(TestCase):
""" """
...@@ -120,6 +133,9 @@ class TestBackends(TestCase): ...@@ -120,6 +133,9 @@ class TestBackends(TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
provider.on_review_callback(None) provider.on_review_callback(None)
with self.assertRaises(NotImplementedError):
provider.on_review_saved(None)
def test_null_provider(self): def test_null_provider(self):
""" """
Assert that the Null provider does nothing Assert that the Null provider does nothing
...@@ -132,3 +148,4 @@ class TestBackends(TestCase): ...@@ -132,3 +148,4 @@ class TestBackends(TestCase):
self.assertIsNone(provider.stop_exam_attempt(None, None)) self.assertIsNone(provider.stop_exam_attempt(None, None))
self.assertIsNone(provider.get_software_download_url()) self.assertIsNone(provider.get_software_download_url())
self.assertIsNone(provider.on_review_callback(None)) self.assertIsNone(provider.on_review_callback(None))
self.assertIsNone(provider.on_review_saved(None))
...@@ -24,3 +24,20 @@ CONTACT_EMAIL = ( ...@@ -24,3 +24,20 @@ CONTACT_EMAIL = (
settings.PROCTORING_SETTINGS['CONTACT_EMAIL'] if settings.PROCTORING_SETTINGS['CONTACT_EMAIL'] if
'CONTACT_EMAIL' in settings.PROCTORING_SETTINGS else getattr(settings, 'CONTACT_EMAIL', FROM_EMAIL) '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): ...@@ -75,7 +75,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"id", "created", "modified", "user", "started_at", "completed_at", "id", "created", "modified", "user", "started_at", "completed_at",
"external_id", "status", "proctored_exam", "allowed_time_limit_mins", "external_id", "status", "proctored_exam", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt", "taking_as_proctored", "last_poll_timestamp", "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 || {}; ...@@ -14,6 +14,7 @@ var edx = edx || {};
this.proctored_exams = options.proctored_exams; this.proctored_exams = options.proctored_exams;
this.proctored_exam_allowance_view = options.proctored_exam_allowance_view; this.proctored_exam_allowance_view = options.proctored_exam_allowance_view;
this.course_id = options.course_id; this.course_id = options.course_id;
this.allowance_types = options.allowance_types;
this.model = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceModel(); this.model = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceModel();
_.bindAll(this, "render"); _.bindAll(this, "render");
this.loadTemplateData(); this.loadTemplateData();
...@@ -156,11 +157,9 @@ var edx = edx || {}; ...@@ -156,11 +157,9 @@ var edx = edx || {};
}, },
render: function () { render: function () {
var allowance_types = ['Additional time (minutes)'];
$(this.el).html(this.template({ $(this.el).html(this.template({
proctored_exams: this.proctored_exams, proctored_exams: this.proctored_exams,
allowance_types: allowance_types allowance_types: this.allowance_types
})); }));
this.$form = { this.$form = {
......
...@@ -8,6 +8,12 @@ var edx = edx || {}; ...@@ -8,6 +8,12 @@ var edx = edx || {};
edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = Backbone.View.extend({ edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView = Backbone.View.extend({
initialize: function () { 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.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 */
...@@ -121,6 +127,20 @@ var edx = edx || {}; ...@@ -121,6 +127,20 @@ var edx = edx || {};
}, },
render: function () { render: function () {
if (this.template !== null) { 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()}); var html = this.template({proctored_exam_allowances: this.collection.toJSON()});
this.$el.html(html); this.$el.html(html);
} }
...@@ -132,7 +152,8 @@ var edx = edx || {}; ...@@ -132,7 +152,8 @@ var edx = edx || {};
var add_allowance_view = new edx.instructor_dashboard.proctoring.AddAllowanceView({ var add_allowance_view = new edx.instructor_dashboard.proctoring.AddAllowanceView({
course_id: self.course_id, course_id: self.course_id,
proctored_exams: self.proctoredExamCollection.toJSON(), 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 || {}; ...@@ -174,6 +174,7 @@ var edx = edx || {};
if (!confirm(gettext('Are you sure you want to remove this student\'s exam attempt?'))) { if (!confirm(gettext('Are you sure you want to remove this student\'s exam attempt?'))) {
return; return;
} }
$('body').css('cursor', 'wait');
var $target = $(event.currentTarget); var $target = $(event.currentTarget);
var attemptId = $target.data("attemptId"); var attemptId = $target.data("attemptId");
...@@ -187,6 +188,7 @@ var edx = edx || {}; ...@@ -187,6 +188,7 @@ var edx = edx || {};
success: function () { success: function () {
// fetch the attempts again. // fetch the attempts again.
self.hydrate(); self.hydrate();
$('body').css('cursor', 'auto');
} }
}); });
} }
......
...@@ -9,7 +9,7 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -9,7 +9,7 @@ describe('ProctoredExamAddAllowanceView', function () {
created: "2015-08-10T09:15:45Z", created: "2015-08-10T09:15:45Z",
id: 1, id: 1,
modified: "2015-08-10T09:15:45Z", modified: "2015-08-10T09:15:45Z",
key: "Additional time (minutes)", key: "additional_time_granted",
value: "1", value: "1",
proctored_exam: { proctored_exam: {
content_id: "i4x://edX/DemoX/sequential/9f5e9b018a244ea38e5d157e0019e60c", content_id: "i4x://edX/DemoX/sequential/9f5e9b018a244ea38e5d157e0019e60c",
...@@ -59,8 +59,8 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -59,8 +59,8 @@ describe('ProctoredExamAddAllowanceView', function () {
'<label>Allowance Type</label>' + '<label>Allowance Type</label>' +
'</td><td><select id="allowance_type">' + '</td><td><select id="allowance_type">' +
'<% _.each(allowance_types, function(allowance_type){ %>' + '<% _.each(allowance_types, function(allowance_type){ %>' +
'<option value="<%= allowance_type %>">' + '<option value="<%= allowance_type[0] %>">' +
'<%- interpolate(gettext(" %(allowance_type)s "), { allowance_type: allowance_type }, true) %>' + '<%= allowance_type[1] %>' +
'</option>' + '</option>' +
'<% }); %>' + '<% }); %>' +
'</select></td></tr><tr><td>' + '</select></td></tr><tr><td>' +
...@@ -101,7 +101,7 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -101,7 +101,7 @@ describe('ProctoredExamAddAllowanceView', function () {
'<td>N/A</td><td>N/A</td>' + '<td>N/A</td><td>N/A</td>' +
'<% } %>' + '<% } %>' +
'<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>' +
'<td>' + '<td>' +
'<%= proctored_exam_allowance.value %>' + '<%= proctored_exam_allowance.value %>' +
...@@ -185,7 +185,7 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -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');
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('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'); expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('Test Exam');
// add the proctored exam allowance // add the proctored exam allowance
...@@ -213,7 +213,7 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -213,7 +213,7 @@ describe('ProctoredExamAddAllowanceView', function () {
//select the form values //select the form values
$('#proctored_exam').val('Test Exam'); $('#proctored_exam').val('Test Exam');
$('#allowance_type').val('Additional time (minutes)'); $('#allowance_type').val('additional_time_granted');
$('#allowance_value').val('1'); $('#allowance_value').val('1');
$("#user_info").val('testuser1'); $("#user_info").val('testuser1');
...@@ -227,7 +227,7 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -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');
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('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'); expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).toContain('Test Exam');
}); });
it("should send error when adding proctored exam allowance", function () { it("should send error when adding proctored exam allowance", function () {
...@@ -254,7 +254,7 @@ describe('ProctoredExamAddAllowanceView', 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');
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('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'); expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html()).not.toContain('Test Exam');
// add the proctored exam allowance // add the proctored exam allowance
...@@ -282,7 +282,7 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -282,7 +282,7 @@ describe('ProctoredExamAddAllowanceView', function () {
//select the form values //select the form values
// invalid user_info returns error // invalid user_info returns error
$('#proctored_exam').val('Test Exam'); $('#proctored_exam').val('Test Exam');
$('#allowance_type').val('Additional time (minutes)'); $('#allowance_type').val('additional_time_granted');
$('#allowance_value').val('2'); $('#allowance_value').val('2');
$("#user_info").val('testuser112321'); $("#user_info").val('testuser112321');
...@@ -299,7 +299,7 @@ describe('ProctoredExamAddAllowanceView', function () { ...@@ -299,7 +299,7 @@ describe('ProctoredExamAddAllowanceView', function () {
//select the form values //select the form values
// empty value returns error // empty value returns error
$('#proctored_exam').val('Test Exam'); $('#proctored_exam').val('Test Exam');
$('#allowance_type').val('Additional time (minutes)'); $('#allowance_type').val('Additional Time (minutes)');
$('#allowance_value').val(''); $('#allowance_value').val('');
$("#user_info").val('testuser1'); $("#user_info").val('testuser1');
......
...@@ -59,7 +59,7 @@ describe('ProctoredExamAllowanceView', function () { ...@@ -59,7 +59,7 @@ describe('ProctoredExamAllowanceView', function () {
'<td>N/A</td><td>N/A</td>' + '<td>N/A</td><td>N/A</td>' +
'<% } %>' + '<% } %>' +
'<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>' +
'<td>' + '<td>' +
'<%= proctored_exam_allowance.value %>' + '<%= proctored_exam_allowance.value %>' +
......
...@@ -23,8 +23,8 @@ ...@@ -23,8 +23,8 @@
<td> <td>
<select id="allowance_type"> <select id="allowance_type">
<% _.each(allowance_types, function(allowance_type){ %> <% _.each(allowance_types, function(allowance_type){ %>
<option value="<%= allowance_type %>"> <option value="<%= allowance_type[0] %>">
<%- interpolate(gettext(' %(allowance_type)s '), { allowance_type: allowance_type }, true) %> <%= allowance_type[1] %>
</option> </option>
<% }); %> <% }); %>
</select> </select>
......
...@@ -23,23 +23,24 @@ ...@@ -23,23 +23,24 @@
<% _.each(proctored_exam_allowances, function(proctored_exam_allowance){ %> <% _.each(proctored_exam_allowances, function(proctored_exam_allowance){ %>
<tr class="allowance-items"> <tr class="allowance-items">
<td> <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> </td>
<% if (proctored_exam_allowance.user){ %> <% if (proctored_exam_allowance.user){ %>
<td> <td>
<%- interpolate(gettext(' %(username)s '), { username: proctored_exam_allowance.user.username }, true) %> <%= proctored_exam_allowance.user.username %>
</td> </td>
<td> <td>
<%- interpolate(gettext(' %(email)s '), { email: proctored_exam_allowance.user.email }, true) %> <%= proctored_exam_allowance.user.email %>
</td> </td>
<% }else{ %> <% }else{ %>
<td>N/A</td> <td>N/A</td>
<td>N/A</td> <td>N/A</td>
<% } %> <% } %>
<td> <td>
<%- interpolate(gettext(' %(allowance_name)s '), { allowance_name: proctored_exam_allowance.key }, true) %> <%= proctored_exam_allowance.key_display_name %>
</td> </td>
<td><%= proctored_exam_allowance.value %></td> <td>
<%- proctored_exam_allowance.value %></td>
<td> <td>
<a data-exam-id="<%= proctored_exam_allowance.proctored_exam.id %>" <a data-exam-id="<%= proctored_exam_allowance.proctored_exam.id %>"
data-key-name="<%= proctored_exam_allowance.key %>" data-key-name="<%= proctored_exam_allowance.key %>"
......
...@@ -54,6 +54,7 @@ from edx_proctoring.models import ( ...@@ -54,6 +54,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAllowance, ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt, ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
ProctoredExamReviewPolicy,
) )
from .utils import ( from .utils import (
...@@ -373,6 +374,24 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -373,6 +374,24 @@ class ProctoredExamApiTests(LoggedInTestCase):
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id) attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id)
self.assertGreater(attempt_id, 0) 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): def test_attempt_with_allowance(self):
""" """
Create an unstarted exam attempt with additional time. Create an unstarted exam attempt with additional time.
...@@ -381,7 +400,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -381,7 +400,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
add_allowance_for_user( add_allowance_for_user(
self.proctored_exam_id, self.proctored_exam_id,
self.user.username, self.user.username,
"Additional time (minutes)", ProctoredExamStudentAllowance.ADDITIONAL_TIME_GRANTED,
str(allowed_extra_time) str(allowed_extra_time)
) )
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id) attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id)
...@@ -1297,7 +1316,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1297,7 +1316,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, { ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Proctored Option Available', 'short_description': 'Proctored Option Available',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1305,7 +1324,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1305,7 +1324,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.declined, { ProctoredExamStudentAttemptStatus.declined, {
'status': ProctoredExamStudentAttemptStatus.declined, 'status': ProctoredExamStudentAttemptStatus.declined,
'short_description': 'Taking As Open Exam', 'short_description': 'Taking As Open Exam',
'suggested_icon': 'fa-unlock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1345,7 +1364,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1345,7 +1364,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.created, { ProctoredExamStudentAttemptStatus.created, {
'status': ProctoredExamStudentAttemptStatus.created, 'status': ProctoredExamStudentAttemptStatus.created,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1353,7 +1372,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1353,7 +1372,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_start, { ProctoredExamStudentAttemptStatus.ready_to_start, {
'status': ProctoredExamStudentAttemptStatus.ready_to_start, 'status': ProctoredExamStudentAttemptStatus.ready_to_start,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1361,7 +1380,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1361,7 +1380,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.started, { ProctoredExamStudentAttemptStatus.started, {
'status': ProctoredExamStudentAttemptStatus.started, 'status': ProctoredExamStudentAttemptStatus.started,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1369,7 +1388,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1369,7 +1388,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.ready_to_submit, { ProctoredExamStudentAttemptStatus.ready_to_submit, {
'status': ProctoredExamStudentAttemptStatus.ready_to_submit, 'status': ProctoredExamStudentAttemptStatus.ready_to_submit,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False 'in_completed_state': False
} }
) )
...@@ -1400,7 +1419,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1400,7 +1419,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, { ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': '',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1471,7 +1490,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1471,7 +1490,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
ProctoredExamStudentAttemptStatus.eligible, { ProctoredExamStudentAttemptStatus.eligible, {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': '',
'in_completed_state': False 'in_completed_state': False
} }
), ),
...@@ -1524,7 +1543,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1524,7 +1543,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
expected = { expected = {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
'suggested_icon': 'fa-lock', 'suggested_icon': '',
'in_completed_state': False 'in_completed_state': False
} }
......
""" """
All tests for the models.py All tests for the models.py
""" """
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAllowanceHistory, \ from edx_proctoring.models import (
ProctoredExamStudentAttempt, ProctoredExamStudentAttemptHistory ProctoredExam,
ProctoredExamStudentAllowance,
ProctoredExamStudentAllowanceHistory,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
ProctoredExamReviewPolicy,
ProctoredExamReviewPolicyHistory,
)
from .utils import ( from .utils import (
LoggedInTestCase LoggedInTestCase
...@@ -166,3 +173,82 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase): ...@@ -166,3 +173,82 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
with self.assertNumQueries(1): with self.assertNumQueries(1):
exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts('a/b/c') exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts('a/b/c')
self.assertEqual(len(exam_attempts), 90) 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 ...@@ -3,6 +3,7 @@ Helpers for the HTTP APIs
""" """
import pytz import pytz
import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -10,6 +11,13 @@ from rest_framework.views import APIView ...@@ -10,6 +11,13 @@ from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from edx_proctoring.models import (
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory,
)
log = logging.getLogger(__name__)
class AuthenticatedAPIView(APIView): class AuthenticatedAPIView(APIView):
""" """
...@@ -82,3 +90,27 @@ def humanized_time(time_in_minutes): ...@@ -82,3 +90,27 @@ def humanized_time(time_in_minutes):
human_time = template.format(num_of_hours=hours, num_of_minutes=minutes) human_time = template.format(num_of_hours=hours, num_of_minutes=minutes)
return human_time 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): ...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths):
setup( setup(
name='edx-proctoring', name='edx-proctoring',
version='0.9.10', version='0.9.14',
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