Merge pull request #11080 from edx/saleem-latif/SOL-1529

SOL-1529: Allow PMs to Invalidate Certificates
This commit is contained in:
Matt Drayer
2016-01-04 10:46:27 -05:00
19 changed files with 1399 additions and 29 deletions

View File

@@ -0,0 +1,16 @@
// Backbone.js Application Collection: CertificateInvalidationCollection
/*global define, RequireJS */
;(function(define) {
'use strict';
define(
['backbone', 'js/certificates/models/certificate_invalidation'],
function(Backbone, CertificateInvalidation) {
return Backbone.Collection.extend({
model: CertificateInvalidation
});
}
);
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,31 @@
// Backbone.js Page Object Factory: Certificate Invalidation Factory
/*global define, RequireJS */
;(function(define) {
'use strict';
define(
[
'js/certificates/views/certificate_invalidation_view',
'js/certificates/collections/certificate_invalidation_collection'
],
function(CertificateInvalidationView, CertificateInvalidationCollection) {
return function(certificate_invalidation_collection_json, certificate_invalidation_url) {
var certificate_invalidation_collection = new CertificateInvalidationCollection(
JSON.parse(certificate_invalidation_collection_json), {
parse: true,
canBeEmpty: true,
url: certificate_invalidation_url
}
);
var certificate_invalidation_view = new CertificateInvalidationView({
collection: certificate_invalidation_collection
});
certificate_invalidation_view.render();
};
}
);
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,35 @@
// Backbone.js Application Model: CertificateInvalidation
/*global define, RequireJS */
;(function(define) {
'use strict';
define(
['underscore', 'underscore.string', 'gettext', 'backbone'],
function(_, str, gettext, Backbone) {
return Backbone.Model.extend({
idAttribute: 'id',
defaults: {
user: '',
invalidated_by: '',
created: '',
notes: ''
},
url: function() {
return this.get('url');
},
validate: function(attrs) {
if (!_.str.trim(attrs.user)) {
// A username or email must be provided for certificate invalidation
return gettext('Student username/email field is required and can not be empty. ' +
'Kindly fill in username/email and then press "Invalidate Certificate" button.');
}
}
});
}
);
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,121 @@
// Backbone Application View: CertificateInvalidationView
/*global define, RequireJS */
;(function(define) {
'use strict';
define(
['jquery', 'underscore', 'gettext', 'backbone', 'js/certificates/models/certificate_invalidation'],
function($, _, gettext, Backbone, CertificateInvalidationModel) {
return Backbone.View.extend({
el: "#certificate-invalidation",
messages: "div.message",
events: {
'click #invalidate-certificate': 'invalidateCertificate',
'click .re-validate-certificate': 'reValidateCertificate'
},
initialize: function() {
this.listenTo(this.collection, 'change add remove', this.render);
},
render: function() {
var template = this.loadTemplate('certificate-invalidation');
this.$el.html(template({certificate_invalidations: this.collection.models}));
},
loadTemplate: function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
return _.template(templateText);
},
invalidateCertificate: function() {
var user = this.$("#certificate-invalidation-user").val();
var notes = this.$("#certificate-invalidation-notes").val();
var certificate_invalidation = new CertificateInvalidationModel({
url: this.collection.url,
user: user,
notes: notes
});
if (this.collection.findWhere({user: user})) {
this.showMessage(
gettext("Certificate of ") + user +
gettext(" has already been invalidated. Please check your spelling and retry."
));
}
else if (certificate_invalidation.isValid()) {
var self = this;
certificate_invalidation.save(null, {
wait: true,
success: function(model) {
self.collection.add(model);
self.showMessage(
gettext('Certificate has been successfully invalidated for ') + user + '.'
);
},
error: function(model, response) {
try {
var response_data = JSON.parse(response.responseText);
self.showMessage(response_data.message);
}
catch(exception) {
self.showMessage(gettext("Server Error, Please refresh the page and try again."));
}
}
});
}
else {
this.showMessage(certificate_invalidation.validationError);
}
},
reValidateCertificate: function(event) {
var certificate_invalidation = $(event.target).data();
var model = this.collection.get(certificate_invalidation),
self = this;
if (model) {
model.destroy({
success: function() {
self.showMessage(gettext('The certificate for this learner has been re-validated and ' +
'the system is re-running the grade for this learner.'));
},
error: function(model, response) {
try {
var response_data = JSON.parse(response.responseText);
self.showMessage(response_data.message);
}
catch(exception) {
self.showMessage(gettext("Server Error, Please refresh the page and try again."));
}
},
wait: true,
data: JSON.stringify(model.attributes)
});
}
else {
self.showMessage(gettext('Could not find Certificate Invalidation in the list. ' +
'Please refresh the page and try again'));
}
},
isEmailAddress: function validateEmail(email) {
var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
return re.test(email);
},
showMessage: function(message) {
$(this.messages + ">p" ).remove();
this.$(this.messages).removeClass('hidden').append("<p>"+ gettext(message) + "</p>");
}
});
}
);
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,268 @@
/*global define */
define([
'jquery',
'common/js/spec_helpers/ajax_helpers',
'js/certificates/models/certificate_invalidation',
'js/certificates/views/certificate_invalidation_view',
'js/certificates/collections/certificate_invalidation_collection'
],
function($, AjaxHelpers, CertificateInvalidationModel, CertificateInvalidationView,
CertificateInvalidationCollection) {
'use strict';
describe("Field validation of invalidation model.", function() {
var certificate_invalidation = null;
var assertValid = function(fields, isValid, expectedErrors) {
certificate_invalidation.set(fields);
var errors = certificate_invalidation.validate(certificate_invalidation.attributes);
if (isValid) {
expect(errors).toBe(undefined);
} else {
expect(errors).toEqual(expectedErrors);
}
};
var EXPECTED_ERRORS = {
user_name_or_email_required: 'Student username/email field is required and can not be empty. ' +
'Kindly fill in username/email and then press "Invalidate Certificate" button.'
};
beforeEach(function() {
certificate_invalidation = new CertificateInvalidationModel({user: 'test_user'});
certificate_invalidation.set({
notes: "Test notes"
});
});
it("accepts valid email addresses", function() {
assertValid({user: "bob@example.com"}, true);
assertValid({user: "bob+smith@example.com"}, true);
assertValid({user: "bob+smith@example.com"}, true);
assertValid({user: "bob+smith@example.com"}, true);
assertValid({user: "bob@test.example.com"}, true);
assertValid({user: "bob@test-example.com"}, true);
});
it("displays username or email required error", function() {
assertValid({user: ""}, false, EXPECTED_ERRORS.user_name_or_email_required);
});
});
describe("Certificate invalidation collection initialization and updates.",
function() {
var certificate_invalidations = null,
certificate_invalidation_url = 'test/url/';
var certificate_invalidations_json = [
{
id: 1,
user: "test1",
invalidated_by: 2,
created: "Thursday, October 29, 2015",
notes: "test notes for test certificate invalidation"
},
{
id: 2,
user: "test2",
invalidated_by: 2,
created: "Thursday, October 29, 2015",
notes: "test notes for test certificate invalidation"
}
];
beforeEach(function() {
certificate_invalidations = new CertificateInvalidationCollection(certificate_invalidations_json, {
parse: true,
canBeEmpty: true,
url: certificate_invalidation_url
});
});
it("has 2 models in the collection after initialization", function() {
expect(certificate_invalidations.models.length).toEqual(2);
});
it("model is removed from collection on destroy", function() {
var model = certificate_invalidations.get({id: 2});
model.destroy();
expect(certificate_invalidations.models.length).toEqual(1);
expect(certificate_invalidations.get({id: 2})).toBe(undefined);
});
}
);
describe("Certificate invalidation success/error messages on add/remove invalidations.", function() {
var view = null,
certificate_invalidation_url = 'test/url/',
user_name_field = null,
notes_field = null,
invalidate_button=null,
duplicate_user='test2',
new_user='test4@test.com',
requests=null;
var messages = {
error: {
empty_user_name_email: 'Student username/email field is required and can not be empty. ' +
'Kindly fill in username/email and then press "Invalidate Certificate" button.',
duplicate_user: "Certificate of " + (duplicate_user) + " has already been invalidated. " +
"Please check your spelling and retry.",
server_error: "Server Error, Please refresh the page and try again.",
from_server: "Test Message from server"
},
success: {
saved: "Certificate has been successfully invalidated for " + new_user + '.',
re_validated: 'The certificate for this learner has been re-validated and ' +
'the system is re-running the grade for this learner.'
}
};
var certificate_invalidations_json = [
{
id: 1,
user: "test1",
invalidated_by: 2,
created: "Thursday, October 29, 2015",
notes: "test notes for test certificate invalidation"
},
{
id: 2,
user: "test2",
invalidated_by: 2,
created: "Thursday, October 29, 2015",
notes: "test notes for test certificate invalidation"
}
];
beforeEach(function() {
setFixtures();
var fixture =readFixtures(
"templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore"
);
setFixtures(
"<div class='certificate-invalidation-container'>" +
" <h2>Invalidate Certificates</h2> " +
" <div id='certificate-invalidation'></div>" +
"</div>" +
"<script type='text/template' id='certificate-invalidation-tpl'>" + fixture + "</script>"
);
var certificate_invalidations = new CertificateInvalidationCollection(certificate_invalidations_json, {
parse: true,
canBeEmpty: true,
url: certificate_invalidation_url,
generate_certificates_url: certificate_invalidation_url
});
view = new CertificateInvalidationView({collection: certificate_invalidations});
view.render();
user_name_field = $("#certificate-invalidation-user");
notes_field = $("#certificate-invalidation-notes");
invalidate_button = $("#invalidate-certificate");
requests = AjaxHelpers.requests(this);
});
it("verifies view is initialized and rendered successfully", function() {
expect(view).not.toBe(undefined);
expect(view.$el.find('table tbody tr').length).toBe(2);
});
it("verifies view is rendered on add/remove to collection", function() {
var user = 'test3',
notes = 'test3 notes',
model = new CertificateInvalidationModel({user: user, notes: notes});
// Add another model in collection and verify it is rendered
view.collection.add(model);
expect(view.$el.find('table tbody tr').length).toBe(3);
expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()).
toMatch(notes);
expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()).
toMatch(user);
// Remove a model from collection
var collection_model = view.collection.get({id: 2});
collection_model.destroy();
// Verify view is updated
expect(view.$el.find('table tbody tr').length).toBe(2);
});
it("verifies view error message on duplicate certificate validation.", function() {
$(user_name_field).val(duplicate_user);
$(invalidate_button).click();
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.duplicate_user);
});
it("verifies view error message on empty username/email field.", function() {
$(user_name_field).val("");
$(invalidate_button).click();
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.empty_user_name_email);
});
it("verifies view success message on certificate invalidation.", function() {
$(user_name_field).val(new_user);
$(notes_field).val("test notes for user test4");
$(invalidate_button).click();
AjaxHelpers.respondWithJson(
requests,
{
id: 4,
user: 'test4',
validated_by: 5,
created: "Thursday, December 29, 2015",
notes: "test notes for user test4"
}
);
expect($("#certificate-invalidation div.message").text()).toEqual(messages.success.saved);
});
it("verifies view server error if server returns unknown response.", function() {
$(user_name_field).val(new_user);
$(notes_field).val("test notes for user test4");
$(invalidate_button).click();
// Response with empty body
AjaxHelpers.respondWithTextError(requests, 400, "");
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.server_error);
});
it("verifies certificate re-validation request and success message.", function() {
var user = 'test1',
re_validate_certificate = "div.certificate-invalidation-container table tr:contains('" +
user + "') td .re-validate-certificate";
$(re_validate_certificate).click();
AjaxHelpers.respondWithJson(requests, {});
expect($("#certificate-invalidation div.message").text()).toEqual(messages.success.re_validated);
});
it("verifies error message from server is displayed.", function() {
var user = 'test1',
re_validate_certificate = "div.certificate-invalidation-container table tr:contains('" +
user + "') td .re-validate-certificate";
$(re_validate_certificate).click();
AjaxHelpers.respondWithError(requests, 400, {
success: false,
message: messages.error.from_server
});
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.from_server);
});
});
}
);

View File

@@ -655,6 +655,7 @@
'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js',
'lms/include/js/spec/instructor_dashboard/student_admin_spec.js',
'lms/include/js/spec/instructor_dashboard/certificates_exception_spec.js',
'lms/include/js/spec/instructor_dashboard/certificates_invalidation_spec.js',
'lms/include/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js',
'lms/include/js/spec/instructor_dashboard/certificates_spec.js',
'lms/include/js/spec/student_account/account_spec.js',

View File

@@ -2159,16 +2159,18 @@ input[name="subject"] {
}
}
.student-username-or-email {
width: 300px;
margin-bottom: 10px;
}
.notes-field {
width: 400px;
}
#certificate-white-list-editor {
padding-top: 5px;
.certificate-exception-inputs {
.student-username-or-email {
width: 300px;
margin-bottom: 10px;
}
.notes-field {
width: 400px;
}
p + p {
margin-top: 5px;
}
@@ -2178,7 +2180,7 @@ input[name="subject"] {
}
}
.white-listed-students {
.white-listed-students, .invalidation-history {
margin-top: 10px;
padding-top: 5px;
table {