Commit f6872a0f by Matt Drayer

Merge pull request #10750 from edx/asadiqbal08/SOL-1417-CSV

SOL-1417 Certs: Allow CSV upload for Cert Exceptions
parents 47b0d492 7c68d80c
...@@ -1001,7 +1001,7 @@ class CertificatesPage(PageObject): ...@@ -1001,7 +1001,7 @@ class CertificatesPage(PageObject):
Wait for Certificate Exceptions to be rendered on page Wait for Certificate Exceptions to be rendered on page
""" """
self.wait_for_element_visibility( self.wait_for_element_visibility(
'div.certificate_exception-container', 'div.certificate-exception-container',
'Certificate Exception Section is visible' 'Certificate Exception Section is visible'
) )
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible') self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
...@@ -1097,7 +1097,7 @@ class CertificatesPage(PageObject): ...@@ -1097,7 +1097,7 @@ class CertificatesPage(PageObject):
""" """
Returns the "Certificate Exceptions" section. Returns the "Certificate Exceptions" section.
""" """
return self.get_selector('div.certificate_exception-container') return self.get_selector('div.certificate-exception-container')
@property @property
def last_certificate_exception(self): def last_certificate_exception(self):
......
...@@ -18,6 +18,8 @@ from certificates.models import CertificateGenerationConfiguration, CertificateS ...@@ -18,6 +18,8 @@ from certificates.models import CertificateGenerationConfiguration, CertificateS
GeneratedCertificate GeneratedCertificate
from certificates import api as certs_api from certificates import api as certs_api
from student.models import CourseEnrollment from student.models import CourseEnrollment
from django.core.files.uploadedfile import SimpleUploadedFile
import io
@attr('shard_1') @attr('shard_1')
...@@ -765,3 +767,161 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase): ...@@ -765,3 +767,161 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase):
res_json['message'], res_json['message'],
u"Invalid data, user_id must be present for all certificate exceptions." u"Invalid data, user_id must be present for all certificate exceptions."
) )
@attr('shard_1')
@ddt.ddt
class TestCertificatesInstructorApiBulkWhiteListExceptions(SharedModuleStoreTestCase):
"""
Test Bulk certificates white list exceptions from csv file
"""
@classmethod
def setUpClass(cls):
super(TestCertificatesInstructorApiBulkWhiteListExceptions, cls).setUpClass()
cls.course = CourseFactory.create()
cls.url = reverse('generate_bulk_certificate_exceptions',
kwargs={'course_id': cls.course.id})
def setUp(self):
super(TestCertificatesInstructorApiBulkWhiteListExceptions, self).setUp()
self.global_staff = GlobalStaffFactory()
self.enrolled_user_1 = UserFactory(
username='TestStudent1',
email='test_student1@example.com',
first_name='Enrolled',
last_name='Student'
)
self.enrolled_user_2 = UserFactory(
username='TestStudent2',
email='test_student2@example.com',
first_name='Enrolled',
last_name='Student'
)
self.not_enrolled_student = UserFactory(
username='NotEnrolledStudent',
email='nonenrolled@test.com',
first_name='NotEnrolled',
last_name='Student'
)
CourseEnrollment.enroll(self.enrolled_user_1, self.course.id)
CourseEnrollment.enroll(self.enrolled_user_2, self.course.id)
# Global staff can see the certificates section
self.client.login(username=self.global_staff.username, password="test")
def test_create_white_list_exception_record(self):
"""
Happy path test to create a single new white listed record
"""
csv_content = "test_student1@example.com,dummy_notes\n" \
"test_student2@example.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
self.assertEquals(len(data['general_errors']), 0)
self.assertEquals(len(data['row_errors']['data_format_error']), 0)
self.assertEquals(len(data['row_errors']['user_not_exist']), 0)
self.assertEquals(len(data['row_errors']['user_already_white_listed']), 0)
self.assertEquals(len(data['row_errors']['user_not_enrolled']), 0)
self.assertEquals(len(data['success']), 2)
self.assertEquals(len(CertificateWhitelist.objects.all()), 2)
def test_invalid_data_format_in_csv(self):
"""
Try uploading a CSV file with invalid data formats and verify the errors.
"""
csv_content = "test_student1@example.com,test,1,USA\n" \
"test_student2@example.com,test,1"
data = self.upload_file(csv_content=csv_content)
self.assertEquals(len(data['row_errors']['data_format_error']), 2)
self.assertEquals(len(data['general_errors']), 0)
self.assertEquals(len(data['success']), 0)
self.assertEquals(len(CertificateWhitelist.objects.all()), 0)
def test_file_upload_type_not_csv(self):
"""
Try uploading some non-CSV file e.g. .JPG file and verify that it is rejected
"""
uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read())
response = self.client.post(self.url, {'students_list': uploaded_file})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertNotEquals(len(data['general_errors']), 0)
self.assertEquals(data['general_errors'][0], 'Make sure that the file you upload is in CSV format with '
'no extraneous characters or rows.')
def test_bad_file_upload_type(self):
"""
Try uploading CSV file with invalid binary data and verify that it is rejected
"""
uploaded_file = SimpleUploadedFile("temp.csv", io.BytesIO(b"some initial binary data: \x00\x01").read())
response = self.client.post(self.url, {'students_list': uploaded_file})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertNotEquals(len(data['general_errors']), 0)
self.assertEquals(data['general_errors'][0], 'Could not read uploaded file.')
def test_invalid_email_in_csv(self):
"""
Test failure case of a poorly formatted email field
"""
csv_content = "test_student.example.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
self.assertEquals(len(data['row_errors']['user_not_exist']), 1)
self.assertEquals(len(data['success']), 0)
self.assertEquals(len(CertificateWhitelist.objects.all()), 0)
def test_csv_user_not_enrolled(self):
"""
If the user is not enrolled in the course then there should be a user_not_enrolled error.
"""
csv_content = "nonenrolled@test.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
self.assertEquals(len(data['row_errors']['user_not_enrolled']), 1)
self.assertEquals(len(data['general_errors']), 0)
self.assertEquals(len(data['success']), 0)
def test_certificate_exception_already_exist(self):
"""
Test error if existing user is already in certificates exception list.
"""
CertificateWhitelist.objects.create(
user=self.enrolled_user_1,
course_id=self.course.id,
whitelist=True,
notes=''
)
csv_content = "test_student1@example.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
self.assertEquals(len(data['row_errors']['user_already_white_listed']), 1)
self.assertEquals(len(data['general_errors']), 0)
self.assertEquals(len(data['success']), 0)
self.assertEquals(len(CertificateWhitelist.objects.all()), 1)
def test_csv_file_not_attached(self):
"""
Test when the user does not attach a file
"""
csv_content = "test_student1@example.com,dummy_notes\n" \
"test_student2@example.com,dummy_notes"
uploaded_file = SimpleUploadedFile("temp.csv", csv_content)
response = self.client.post(self.url, {'file_not_found': uploaded_file})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertEquals(len(data['general_errors']), 1)
self.assertEquals(len(data['success']), 0)
def upload_file(self, csv_content):
"""
Upload a csv file.
:return json data
"""
uploaded_file = SimpleUploadedFile("temp.csv", csv_content)
response = self.client.post(self.url, {'students_list': uploaded_file})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
return data
...@@ -12,7 +12,7 @@ import re ...@@ -12,7 +12,7 @@ import re
import time import time
import requests import requests
from django.conf import settings from django.conf import settings
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
from django.views.decorators.http import require_POST, require_http_methods from django.views.decorators.http import require_POST, require_http_methods
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control
from django.core.exceptions import ValidationError, PermissionDenied from django.core.exceptions import ValidationError, PermissionDenied
...@@ -2918,3 +2918,97 @@ def generate_certificate_exceptions(request, course_id, generate_for=None): ...@@ -2918,3 +2918,97 @@ def generate_certificate_exceptions(request, course_id, generate_for=None):
} }
return JsonResponse(response_payload) return JsonResponse(response_payload)
@csrf_exempt
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_global_staff
@require_POST
def generate_bulk_certificate_exceptions(request, course_id): # pylint: disable=invalid-name
"""
Add Students to certificate white list from the uploaded csv file.
:return response in dict format.
{
general_errors: [errors related to csv file e.g. csv uploading, csv attachment, content reading etc. ],
row_errors: {
data_format_error: [users/data in csv file that are not well formatted],
user_not_exist: [csv with none exiting users in LMS system],
user_already_white_listed: [users that are already white listed],
user_not_enrolled: [rows with not enrolled users in the given course]
},
success: [list of successfully added users to the certificate white list model]
}
"""
user_index = 0
notes_index = 1
row_errors_key = ['data_format_error', 'user_not_exist', 'user_already_white_listed', 'user_not_enrolled']
course_key = CourseKey.from_string(course_id)
students, general_errors, success = [], [], []
row_errors = {key: [] for key in row_errors_key}
def build_row_errors(key, _user, row_count):
"""
inner method to build dict of csv data as row errors.
"""
row_errors[key].append(_('user "{user}" in row# {row}').format(user=_user, row=row_count))
if 'students_list' in request.FILES:
try:
upload_file = request.FILES.get('students_list')
if upload_file.name.endswith('.csv'):
students = [row for row in csv.reader(upload_file.read().splitlines())]
else:
general_errors.append(_('Make sure that the file you upload is in CSV format with no '
'extraneous characters or rows.'))
except Exception: # pylint: disable=broad-except
general_errors.append(_('Could not read uploaded file.'))
finally:
upload_file.close()
row_num = 0
for student in students:
row_num += 1
# verify that we have exactly two column in every row either email or username and notes but allow for
# blank lines
if len(student) != 2:
if len(student) > 0:
build_row_errors('data_format_error', student[user_index], row_num)
log.info(u'invalid data/format in csv row# %s', row_num)
continue
user = student[user_index]
try:
user = get_user_by_username_or_email(user)
except ObjectDoesNotExist:
build_row_errors('user_not_exist', user, row_num)
log.info(u'student %s does not exist', user)
else:
if len(CertificateWhitelist.get_certificate_white_list(course_key, user)) > 0:
build_row_errors('user_already_white_listed', user, row_num)
log.warning(u'student %s already exist.', user.username)
# make sure user is enrolled in course
elif not CourseEnrollment.is_enrolled(user, course_key):
build_row_errors('user_not_enrolled', user, row_num)
log.warning(u'student %s is not enrolled in course.', user.username)
else:
CertificateWhitelist.objects.create(
user=user,
course_id=course_key,
whitelist=True,
notes=student[notes_index]
)
success.append(_('user "{username}" in row# {row}').format(username=user.username, row=row_num))
else:
general_errors.append(_('File is not attached.'))
results = {
'general_errors': general_errors,
'row_errors': row_errors,
'success': success
}
return JsonResponse(results)
...@@ -157,4 +157,8 @@ urlpatterns = patterns( ...@@ -157,4 +157,8 @@ urlpatterns = patterns(
url(r'^generate_certificate_exceptions/(?P<generate_for>[^/]*)', url(r'^generate_certificate_exceptions/(?P<generate_for>[^/]*)',
'instructor.views.api.generate_certificate_exceptions', 'instructor.views.api.generate_certificate_exceptions',
name='generate_certificate_exceptions'), name='generate_certificate_exceptions'),
url(r'^generate_bulk_certificate_exceptions',
'instructor.views.api.generate_bulk_certificate_exceptions',
name='generate_bulk_certificate_exceptions'),
) )
...@@ -169,6 +169,10 @@ def instructor_dashboard_2(request, course_id): ...@@ -169,6 +169,10 @@ def instructor_dashboard_2(request, course_id):
'generate_certificate_exceptions', 'generate_certificate_exceptions',
kwargs={'course_id': unicode(course_key), 'generate_for': ''} kwargs={'course_id': unicode(course_key), 'generate_for': ''}
) )
generate_bulk_certificate_exceptions_url = reverse( # pylint: disable=invalid-name
'generate_bulk_certificate_exceptions',
kwargs={'course_id': unicode(course_key)}
)
certificate_exception_view_url = reverse( certificate_exception_view_url = reverse(
'certificate_exception_view', 'certificate_exception_view',
kwargs={'course_id': unicode(course_key)} kwargs={'course_id': unicode(course_key)}
...@@ -183,6 +187,7 @@ def instructor_dashboard_2(request, course_id): ...@@ -183,6 +187,7 @@ def instructor_dashboard_2(request, course_id):
'analytics_dashboard_message': analytics_dashboard_message, 'analytics_dashboard_message': analytics_dashboard_message,
'certificate_white_list': certificate_white_list, 'certificate_white_list': certificate_white_list,
'generate_certificate_exceptions_url': generate_certificate_exceptions_url, 'generate_certificate_exceptions_url': generate_certificate_exceptions_url,
'generate_bulk_certificate_exceptions_url': generate_bulk_certificate_exceptions_url,
'certificate_exception_view_url': certificate_exception_view_url 'certificate_exception_view_url': certificate_exception_view_url
} }
return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context) return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context)
......
...@@ -8,12 +8,13 @@ ...@@ -8,12 +8,13 @@
'js/certificates/views/certificate_whitelist', 'js/certificates/views/certificate_whitelist',
'js/certificates/models/certificate_exception', 'js/certificates/models/certificate_exception',
'js/certificates/views/certificate_whitelist_editor', 'js/certificates/views/certificate_whitelist_editor',
'js/certificates/collections/certificate_whitelist' 'js/certificates/collections/certificate_whitelist',
'js/certificates/views/certificate_bulk_whitelist'
], ],
function($, CertificateWhiteListListView, CertificateExceptionModel, CertificateWhiteListEditorView , function($, CertificateWhiteListListView, CertificateExceptionModel, CertificateWhiteListEditorView ,
CertificateWhiteListCollection){ CertificateWhiteListCollection, CertificateBulkWhiteList){
return function(certificate_white_list_json, generate_certificate_exceptions_url, return function(certificate_white_list_json, generate_certificate_exceptions_url,
certificate_exception_view_url){ certificate_exception_view_url, generate_bulk_certificate_exceptions_url){
var certificateWhiteList = new CertificateWhiteListCollection(JSON.parse(certificate_white_list_json), { var certificateWhiteList = new CertificateWhiteListCollection(JSON.parse(certificate_white_list_json), {
parse: true, parse: true,
...@@ -32,6 +33,10 @@ ...@@ -32,6 +33,10 @@
certificateWhiteListEditorView: certificateWhiteListEditorView certificateWhiteListEditorView: certificateWhiteListEditorView
}).render(); }).render();
new CertificateBulkWhiteList({
bulk_exception_url: generate_bulk_certificate_exceptions_url
}).render();
}; };
} }
); );
......
// Backbone Application View: CertificateBulkWhitelist View
/*global define, RequireJS */
;(function(define){
'use strict';
define([
'jquery',
'underscore',
'gettext',
'backbone'
],
function($, _, gettext, Backbone){
var DOM_SELECTORS = {
bulk_exception: ".bulk-white-list-exception",
upload_csv_button: ".upload-csv-button",
browse_file: ".browse-file",
bulk_white_list_exception_form: "form#bulk-white-list-exception-form"
};
var MESSAGE_GROUP = {
successfully_added: 'successfully-added',
general_errors: 'general-errors',
data_format_error: 'data-format-error',
user_not_exist: 'user-not-exist',
user_already_white_listed: 'user-already-white-listed',
user_not_enrolled: 'user-not-enrolled'
};
return Backbone.View.extend({
el: DOM_SELECTORS.bulk_exception,
events: {
'change #browseBtn': 'chooseFile',
'click .upload-csv-button': 'uploadCSV'
},
initialize: function(options){
// Re-render the view when an item is added to the collection
this.bulk_exception_url = options.bulk_exception_url;
},
render: function(){
var template = this.loadTemplate('certificate-bulk-white-list');
this.$el.html(template());
},
loadTemplate: function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
return _.template(templateText);
},
uploadCSV: function() {
var form = this.$el.find(DOM_SELECTORS.bulk_white_list_exception_form);
var self = this;
form.unbind('submit').submit(function(e) {
var data = new FormData(e.currentTarget);
$.ajax({
dataType: 'json',
type: 'POST',
url: self.bulk_exception_url,
data: data,
processData: false,
contentType: false,
success: function(data_from_server) {
self.display_response(data_from_server);
}
});
e.preventDefault(); // avoid to execute the actual submit of the form.
});
},
display_response: function(data_from_server) {
$(".results").empty();
// Display general error messages
if (data_from_server.general_errors.length) {
var errors = data_from_server.general_errors;
generate_div('msg-error', MESSAGE_GROUP.general_errors, gettext('Errors!'), errors);
}
// Display success message
if (data_from_server.success.length) {
var success_data = data_from_server.success;
generate_div(
'msg-success',
MESSAGE_GROUP.successfully_added,
get_text(success_data.length, MESSAGE_GROUP.successfully_added),
success_data
);
}
// Display data row error messages
if (Object.keys(data_from_server.row_errors).length) {
var row_errors = data_from_server.row_errors;
if (row_errors.data_format_error.length) {
var format_errors = row_errors.data_format_error;
generate_div(
'msg-error',
MESSAGE_GROUP.data_format_error,
get_text(format_errors.length, MESSAGE_GROUP.data_format_error),
format_errors
);
}
if (row_errors.user_not_exist.length) {
var user_not_exist = row_errors.user_not_exist;
generate_div(
'msg-error',
MESSAGE_GROUP.user_not_exist,
get_text(user_not_exist.length, MESSAGE_GROUP.user_not_exist),
user_not_exist
);
}
if (row_errors.user_already_white_listed.length) {
var user_already_white_listed = row_errors.user_already_white_listed;
generate_div(
'msg-error',
MESSAGE_GROUP.user_already_white_listed,
get_text(user_already_white_listed.length, MESSAGE_GROUP.user_already_white_listed),
user_already_white_listed
);
}
if (row_errors.user_not_enrolled.length) {
var user_not_enrolled = row_errors.user_not_enrolled;
generate_div(
'msg-error',
MESSAGE_GROUP.user_not_enrolled,
get_text(user_not_enrolled.length, MESSAGE_GROUP.user_not_enrolled),
user_not_enrolled
);
}
}
function generate_div(div_class, group, heading, display_data) {
// inner function generate div and display response messages.
$('<div/>', {
class: 'message ' + div_class + ' ' + group
}).appendTo('.results').prepend( "<b>" + heading + "</b>" );
for(var i = 0; i < display_data.length; i++){
$('<div/>', {
text: display_data[i]
}).appendTo('.results > .' + div_class + '.' + group);
}
}
function get_text(qty, group) {
// inner function to display appropriate heading text
var text;
switch(group) {
case MESSAGE_GROUP.successfully_added:
text = qty > 1 ? gettext(qty + ' learners are successfully added to exception list'):
gettext(qty + ' learner is successfully added to the exception list');
break;
case MESSAGE_GROUP.data_format_error:
text = qty > 1 ? gettext(qty + ' records are not in correct format'):
gettext(qty + ' record is not in correct format');
break;
case MESSAGE_GROUP.user_not_exist:
text = qty > 1 ? gettext(qty + ' learners do not exist in LMS'):
gettext(qty + ' learner does not exist in LMS');
break;
case MESSAGE_GROUP.user_already_white_listed:
text = qty > 1 ? gettext(qty + ' learners are already white listed'):
gettext(qty + ' learner is already white listed');
break;
case MESSAGE_GROUP.user_not_enrolled:
text = qty > 1 ? gettext(qty + ' learners are not enrolled in course'):
gettext(qty + ' learner is not enrolled in course');
break;
}
return text;
}
},
chooseFile: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
if (event.currentTarget.files.length === 1) {
this.$el.find(DOM_SELECTORS.upload_csv_button).removeClass('is-disabled');
this.$el.find(DOM_SELECTORS.browse_file).val(
event.currentTarget.value.substring(event.currentTarget.value.lastIndexOf("\\") + 1));
}
}
});
}
);
}).call(this, define || RequireJS.define);
...@@ -471,43 +471,6 @@ ...@@ -471,43 +471,6 @@
.enrollment_signup_button { .enrollment_signup_button {
@include margin-right($baseline/4); @include margin-right($baseline/4);
} }
// Custom File upload
.customBrowseBtn {
margin: ($baseline/2) 0;
display: inline-block;
.file-browse {
position:relative;
overflow:hidden;
display: inline;
@include margin-left(-5px);
span.browse{
@include button(simple, $blue);
@include margin-right($baseline);
padding: 6px ($baseline/2);
font-size: 12px;
border-radius: 0 3px 3px 0;
}
input.file_field {
position:absolute;
@include right(0);
top:0;
margin:0;
padding:0;
cursor:pointer;
opacity:0;
filter:alpha(opacity=0);
}
}
& > span, & input[disabled]{
vertical-align: middle;
}
input[disabled] {
@include border-radius(4px 0 0 4px);
@include padding(6px 6px 5px);
border: 1px solid $lightGrey1;
cursor: not-allowed;
}
}
} }
.enroll-option { .enroll-option {
...@@ -1839,6 +1802,15 @@ input[name="subject"] { ...@@ -1839,6 +1802,15 @@ input[name="subject"] {
width: 75%; width: 75%;
} }
.certificate-exception-container {
h3 {
border-bottom: 1px groove black;
display: inline-block;
}
p.under-heading-text {
margin: 12px 0 12px 0;
}
}
} }
input[name="subject"] { input[name="subject"] {
...@@ -2225,3 +2197,40 @@ input[name="subject"] { ...@@ -2225,3 +2197,40 @@ input[name="subject"] {
} }
} }
} }
// Custom File upload
.customBrowseBtn {
margin: ($baseline/2) 0;
display: inline-block;
.file-browse {
position:relative;
overflow:hidden;
display: inline;
@include margin-left(-5px);
span.browse{
@include button(simple, $blue);
@include margin-right($baseline);
padding: 6px ($baseline/2);
font-size: 12px;
border-radius: 0 3px 3px 0;
}
input.file_field {
position:absolute;
@include right(0);
top:0;
margin:0;
padding:0;
cursor:pointer;
opacity:0;
filter:alpha(opacity=0);
}
}
& > span, & input[disabled]{
vertical-align: middle;
}
input[disabled] {
@include border-radius(4px 0 0 4px);
@include padding(6px 6px 5px);
border: 1px solid $lightGrey1;
cursor: not-allowed;
}
}
<h3><%= gettext("Bulk Exceptions") %></h3>
<div class="bulk_white_list_csv">
<p class="under-heading-text">
<%= gettext("You can upload a CSV file of usernames or email addresses to be added to the certificate exceptions white list.") %>
</p>
<form id="bulk-white-list-exception-form" enctype="multipart/form-data">
<div class="customBrowseBtn">
<input disabled="disabled" class="browse-file" placeholder="<%= gettext("Choose File") %>" />
<div class="file-browse btn btn-primary">
<span class="browse"> <%= gettext("Browse") %> </span>
<input class="file_field" id="browseBtn" name="students_list" type="file" accept=".csv"/>
</div>
</div>
<button class="is-disabled upload-csv-button" type="submit"><%= gettext("Upload CSV") %></button>
</form>
<div class="results"></div>
</div>
\ No newline at end of file
<h3><%= gettext("Individual Exceptions") %></h3>
<p class="under-heading-text"> <%= gettext("You can add a username or email address to be added to the certificate exceptions white list.") %></p>
<div class='certificate-exception-inputs'> <div class='certificate-exception-inputs'>
<input class='student-username-or-email' id="certificate-exception" type="text" placeholder="Student email or username" aria-describedby='student-user-name-or-email-tip'> <input class='student-username-or-email' id="certificate-exception" type="text" placeholder="Student email or username" aria-describedby='student-user-name-or-email-tip'>
<textarea class='notes-field' id="notes" rows="10" placeholder="Free text notes" aria-describedby='notes-field-tip'></textarea> <textarea class='notes-field' id="notes" rows="10" placeholder="Free text notes" aria-describedby='notes-field-tip'></textarea>
<input type="button" id="add-exception" value="Add Exception"> <input type="button" id="add-exception" value="Add Exception">
<p id='student-user-name-or-email-tip'><%- gettext("Specify either Student's username or email for whom to create certificate exception") %></p>
<p id='notes-field-tip'><%- gettext("Enter Notes associated with this certificate exception") %></p>
<div class='message'></div> <div class='message'></div>
</div> </div>
...@@ -5,7 +5,7 @@ import json ...@@ -5,7 +5,7 @@ import json
%> %>
<%static:require_module module_name="js/certificates/factories/certificate_whitelist_factory" class_name="CertificateWhitelistFactory"> <%static:require_module module_name="js/certificates/factories/certificate_whitelist_factory" class_name="CertificateWhitelistFactory">
CertificateWhitelistFactory('${json.dumps(certificate_white_list)}', "${generate_certificate_exceptions_url}", "${certificate_exception_view_url}"); CertificateWhitelistFactory('${json.dumps(certificate_white_list)}', "${generate_certificate_exceptions_url}", "${certificate_exception_view_url}", "${generate_bulk_certificate_exceptions_url}");
</%static:require_module> </%static:require_module>
<%page args="section_data"/> <%page args="section_data"/>
...@@ -114,13 +114,15 @@ import json ...@@ -114,13 +114,15 @@ import json
<input type="button" id="btn-start-regenerating-certificates" value="${_('Regenerate Certificates')}" data-endpoint="${section_data['urls']['start_certificate_regeneration']}"/> <input type="button" id="btn-start-regenerating-certificates" value="${_('Regenerate Certificates')}" data-endpoint="${section_data['urls']['start_certificate_regeneration']}"/>
</form> </form>
<div class="message certificate-regeneration-status"></div> <div class="message certificate-regeneration-status"></div>
</div>
<div class="certificate_exception-container"> <div class="certificate-exception-container">
<hr> <hr>
<h2> ${_("Certificate Exceptions")} </h2> <h2> ${_("Certificate Exceptions")} </h2>
<p class="under-heading-text">
${_("Use this to generate certificates for users who did not pass the course but have been given an exception by the Course Team to earn a certificate.")}
</p>
<div class="certificate-exception-section"> <div class="certificate-exception-section">
<p>${_("Use this to generate certificates for users who did not pass the course but have been given an exception by the Course Team to earn a certificate.")} </p> <div class="bulk-white-list-exception"></div>
<br /> <br />
<div id="certificate-white-list-editor"></div> <div id="certificate-white-list-editor"></div>
<div class="white-listed-students" id="white-listed-students"></div> <div class="white-listed-students" id="white-listed-students"></div>
......
...@@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse ...@@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse
## Include Underscore templates ## Include Underscore templates
<%block name="header_extras"> <%block name="header_extras">
% for template_name in ["cohorts", "enrollment-code-lookup-links", "cohort-editor", "cohort-group-header", "cohort-selector", "cohort-form", "notification", "cohort-state", "cohort-discussions-inline", "cohort-discussions-course-wide", "cohort-discussions-category","cohort-discussions-subcategory","certificate-white-list","certificate-white-list-editor"]: % for template_name in ["cohorts", "enrollment-code-lookup-links", "cohort-editor", "cohort-group-header", "cohort-selector", "cohort-form", "notification", "cohort-state", "cohort-discussions-inline", "cohort-discussions-course-wide", "cohort-discussions-category","cohort-discussions-subcategory","certificate-white-list","certificate-white-list-editor","certificate-bulk-white-list"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="instructor/instructor_dashboard_2/${template_name}.underscore" /> <%static:include path="instructor/instructor_dashboard_2/${template_name}.underscore" />
</script> </script>
......
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