Commit 2c1680a9 by Matt Drayer

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

SOL-1529: Allow PMs to Invalidate Certificates
parents 99b02996 9aa0a01c
...@@ -1006,6 +1006,16 @@ class CertificatesPage(PageObject): ...@@ -1006,6 +1006,16 @@ class CertificatesPage(PageObject):
) )
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible') self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
def wait_for_certificate_invalidations_section(self): # pylint: disable=invalid-name
"""
Wait for certificate invalidations section to be rendered on page
"""
self.wait_for_element_visibility(
'div.certificate-invalidation-container',
'Certificate invalidations section is visible.'
)
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
def refresh(self): def refresh(self):
""" """
Refresh Certificates Page and wait for the page to load completely. Refresh Certificates Page and wait for the page to load completely.
...@@ -1064,6 +1074,42 @@ class CertificatesPage(PageObject): ...@@ -1064,6 +1074,42 @@ class CertificatesPage(PageObject):
""" """
self.get_selector('#add-exception').click() self.get_selector('#add-exception').click()
def add_certificate_invalidation(self, student, notes):
"""
Add certificate invalidation for 'student'.
"""
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
self.get_selector('#certificate-invalidation-user').fill(student)
self.get_selector('#certificate-invalidation-notes').fill(notes)
self.get_selector('#invalidate-certificate').click()
self.wait_for_ajax()
self.wait_for(
lambda: student in self.get_selector('div.invalidation-history table tr:last-child td').text,
description='Certificate invalidation added to list.'
)
def remove_first_certificate_invalidation(self):
"""
Remove certificate invalidation from the invalidation list.
"""
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
self.get_selector('div.invalidation-history table tr td .re-validate-certificate').first.click()
self.wait_for_ajax()
def fill_certificate_invalidation_user_name_field(self, student): # pylint: disable=invalid-name
"""
Fill username/email field with given text
"""
self.get_selector('#certificate-invalidation-user').fill(student)
def click_invalidate_certificate_button(self):
"""
Click 'Invalidate Certificate' button in 'certificates invalidations' section
"""
self.get_selector('#invalidate-certificate').click()
@property @property
def generate_certificates_button(self): def generate_certificates_button(self):
""" """
...@@ -1111,4 +1157,18 @@ class CertificatesPage(PageObject): ...@@ -1111,4 +1157,18 @@ class CertificatesPage(PageObject):
""" """
Returns the Message (error/success) in "Certificate Exceptions" section. Returns the Message (error/success) in "Certificate Exceptions" section.
""" """
return self.get_selector('div.message') return self.get_selector('.certificate-exception-container div.message')
@property
def last_certificate_invalidation(self):
"""
Returns last certificate invalidation from "Certificate Invalidations" section.
"""
return self.get_selector('div.certificate-invalidation-container table tr:last-child td')
@property
def certificate_invalidation_message(self): # pylint: disable=invalid-name
"""
Returns the message (error/success) in "Certificate Invalidation" section.
"""
return self.get_selector('.certificate-invalidation-container div.message')
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
from django.conf import settings
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('certificates', '0006_certificatetemplateasset_asset_slug'),
]
operations = [
migrations.CreateModel(
name='CertificateInvalidation',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
('notes', models.TextField(default=None, null=True)),
('active', models.BooleanField(default=True)),
('generated_certificate', models.ForeignKey(to='certificates.GeneratedCertificate')),
('invalidated_by', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
],
),
]
...@@ -253,6 +253,12 @@ class GeneratedCertificate(models.Model): ...@@ -253,6 +253,12 @@ class GeneratedCertificate(models.Model):
self.save() self.save()
def is_valid(self):
"""
Return True if certificate is valid else return False.
"""
return self.status == CertificateStatuses.downloadable
class CertificateGenerationHistory(TimeStampedModel): class CertificateGenerationHistory(TimeStampedModel):
""" """
...@@ -309,6 +315,59 @@ class CertificateGenerationHistory(TimeStampedModel): ...@@ -309,6 +315,59 @@ class CertificateGenerationHistory(TimeStampedModel):
("regenerated" if self.is_regeneration else "generated", self.generated_by, self.created, self.course_id) ("regenerated" if self.is_regeneration else "generated", self.generated_by, self.created, self.course_id)
class CertificateInvalidation(TimeStampedModel):
"""
Model for storing Certificate Invalidation.
"""
generated_certificate = models.ForeignKey(GeneratedCertificate)
invalidated_by = models.ForeignKey(User)
notes = models.TextField(default=None, null=True)
active = models.BooleanField(default=True)
class Meta(object):
app_label = "certificates"
def __unicode__(self):
return u"Certificate %s, invalidated by %s on %s." % \
(self.generated_certificate, self.invalidated_by, self.created)
def deactivate(self):
"""
Deactivate certificate invalidation by setting active to False.
"""
self.active = False
self.save()
@classmethod
def get_certificate_invalidations(cls, course_key, student=None):
"""
Return certificate invalidations filtered based on the provided course and student (if provided),
Returned value is JSON serializable list of dicts, dict element would have the following key-value pairs.
1. id: certificate invalidation id (primary key)
2. user: username of the student to whom certificate belongs
3. invalidated_by: user id of the instructor/support user who invalidated the certificate
4. created: string containing date of invalidation in the following format "December 29, 2015"
5. notes: string containing notes regarding certificate invalidation.
"""
certificate_invalidations = cls.objects.filter(
generated_certificate__course_id=course_key,
active=True,
)
if student:
certificate_invalidations = certificate_invalidations.filter(generated_certificate__user=student)
data = []
for certificate_invalidation in certificate_invalidations:
data.append({
'id': certificate_invalidation.id,
'user': certificate_invalidation.generated_certificate.user.username,
'invalidated_by': certificate_invalidation.invalidated_by.username,
'created': certificate_invalidation.created.strftime("%B %d, %Y"),
'notes': certificate_invalidation.notes,
})
return data
@receiver(post_save, sender=GeneratedCertificate) @receiver(post_save, sender=GeneratedCertificate)
def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=unused-argument def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=unused-argument
""" """
......
...@@ -9,7 +9,7 @@ from student.models import LinkedInAddToProfileConfiguration ...@@ -9,7 +9,7 @@ from student.models import LinkedInAddToProfileConfiguration
from certificates.models import ( from certificates.models import (
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion, GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion,
BadgeImageConfiguration, BadgeImageConfiguration, CertificateInvalidation,
) )
...@@ -35,6 +35,15 @@ class CertificateWhitelistFactory(DjangoModelFactory): ...@@ -35,6 +35,15 @@ class CertificateWhitelistFactory(DjangoModelFactory):
notes = 'Test Notes' notes = 'Test Notes'
class CertificateInvalidationFactory(DjangoModelFactory):
class Meta(object):
model = CertificateInvalidation
notes = 'Test Notes'
active = True
class BadgeAssertionFactory(DjangoModelFactory): class BadgeAssertionFactory(DjangoModelFactory):
class Meta(object): class Meta(object):
model = BadgeAssertion model = BadgeAssertion
......
...@@ -89,7 +89,7 @@ from instructor.views import INVOICE_KEY ...@@ -89,7 +89,7 @@ from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository from submissions import api as sub_api # installed from the edx-submissions repository
from certificates import api as certs_api from certificates import api as certs_api
from certificates.models import CertificateWhitelist, GeneratedCertificate, CertificateStatuses from certificates.models import CertificateWhitelist, GeneratedCertificate, CertificateStatuses, CertificateInvalidation
from bulk_email.models import CourseEmail from bulk_email.models import CourseEmail
from student.models import get_user_by_username_or_email from student.models import get_user_by_username_or_email
...@@ -2839,26 +2839,53 @@ def parse_request_data_and_get_user(request, course_key): ...@@ -2839,26 +2839,53 @@ def parse_request_data_and_get_user(request, course_key):
:param course_key: Course Identifier of the course for whom to process certificate exception :param course_key: Course Identifier of the course for whom to process certificate exception
:return: key-value pairs containing certificate exception data and User object :return: key-value pairs containing certificate exception data and User object
""" """
try: certificate_exception = parse_request_data(request)
certificate_exception = json.loads(request.body or '{}')
except ValueError:
raise ValueError(_('The record is not in the correct format. Please add a valid username or email address.'))
user = certificate_exception.get('user_name', '') or certificate_exception.get('user_email', '') user = certificate_exception.get('user_name', '') or certificate_exception.get('user_email', '')
if not user: if not user:
raise ValueError(_('Student username/email field is required and can not be empty. ' raise ValueError(_('Student username/email field is required and can not be empty. '
'Kindly fill in username/email and then press "Add to Exception List" button.')) 'Kindly fill in username/email and then press "Add to Exception List" button.'))
db_user = get_student(user, course_key)
return certificate_exception, db_user
def parse_request_data(request):
"""
Parse and return request data, raise ValueError in case of invalid JSON data.
:param request: HttpRequest request object.
:return: dict object containing parsed json data.
"""
try:
data = json.loads(request.body or '{}')
except ValueError:
raise ValueError(_('The record is not in the correct format. Please add a valid username or email address.'))
return data
def get_student(username_or_email, course_key):
"""
Retrieve and return User object from db, raise ValueError
if user is does not exists or is not enrolled in the given course.
:param username_or_email: String containing either user name or email of the student.
:param course_key: CourseKey object identifying the current course.
:return: User object
"""
try: try:
db_user = get_user_by_username_or_email(user) student = get_user_by_username_or_email(username_or_email)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise ValueError(_("{user} does not exist in the LMS. Please check your spelling and retry.").format(user=user)) raise ValueError(_("{user} does not exist in the LMS. Please check your spelling and retry.").format(
user=username_or_email
))
# Make Sure the given student is enrolled in the course # Make Sure the given student is enrolled in the course
if not CourseEnrollment.is_enrolled(db_user, course_key): if not CourseEnrollment.is_enrolled(student, course_key):
raise ValueError(_("{user} is not enrolled in this course. Please check your spelling and retry.") raise ValueError(_("{user} is not enrolled in this course. Please check your spelling and retry.")
.format(user=user)) .format(user=username_or_email))
return student
return certificate_exception, db_user
@transaction.non_atomic_requests @transaction.non_atomic_requests
...@@ -3014,3 +3041,142 @@ def generate_bulk_certificate_exceptions(request, course_id): # pylint: disable ...@@ -3014,3 +3041,142 @@ def generate_bulk_certificate_exceptions(request, course_id): # pylint: disable
} }
return JsonResponse(results) return JsonResponse(results)
@transaction.non_atomic_requests
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_global_staff
@require_http_methods(['POST', 'DELETE'])
def certificate_invalidation_view(request, course_id):
"""
Invalidate/Re-Validate students to/from certificate.
:param request: HttpRequest object
:param course_id: course identifier of the course for whom to add/remove certificates exception.
:return: JsonResponse object with success/error message or certificate invalidation data.
"""
course_key = CourseKey.from_string(course_id)
# Validate request data and return error response in case of invalid data
try:
certificate_invalidation_data = parse_request_data(request)
certificate = validate_request_data_and_get_certificate(certificate_invalidation_data, course_key)
except ValueError as error:
return JsonResponse({'message': error.message}, status=400)
# Invalidate certificate of the given student for the course course
if request.method == 'POST':
try:
certificate_invalidation = invalidate_certificate(request, certificate, certificate_invalidation_data)
except ValueError as error:
return JsonResponse({'message': error.message}, status=400)
return JsonResponse(certificate_invalidation)
# Re-Validate student certificate for the course course
elif request.method == 'DELETE':
try:
re_validate_certificate(request, course_key, certificate)
except ValueError as error:
return JsonResponse({'message': error.message}, status=400)
return JsonResponse({}, status=204)
def invalidate_certificate(request, generated_certificate, certificate_invalidation_data):
"""
Invalidate given GeneratedCertificate and add CertificateInvalidation record for future reference or re-validation.
:param request: HttpRequest object
:param generated_certificate: GeneratedCertificate object, the certificate we want to invalidate
:param certificate_invalidation_data: dict object containing data for CertificateInvalidation.
:return: dict object containing updated certificate invalidation data.
"""
if len(CertificateInvalidation.get_certificate_invalidations(
generated_certificate.course_id,
generated_certificate.user,
)) > 0:
raise ValueError(
_("Certificate of {user} has already been invalidated. Please check your spelling and retry.").format(
user=generated_certificate.user.username,
)
)
# Verify that certificate user wants to invalidate is a valid one.
if not generated_certificate.is_valid():
raise ValueError(
_("Certificate for student {user} is already invalid, kindly verify that certificate was generated "
"for this student and then proceed.").format(user=generated_certificate.user.username)
)
# Add CertificateInvalidation record for future reference or re-validation
certificate_invalidation, __ = CertificateInvalidation.objects.update_or_create(
generated_certificate=generated_certificate,
defaults={
'invalidated_by': request.user,
'notes': certificate_invalidation_data.get("notes", ""),
'active': True,
}
)
# Invalidate GeneratedCertificate
generated_certificate.invalidate()
return {
'id': certificate_invalidation.id,
'user': certificate_invalidation.generated_certificate.user.username,
'invalidated_by': certificate_invalidation.invalidated_by.username,
'created': certificate_invalidation.created.strftime("%B %d, %Y"),
'notes': certificate_invalidation.notes,
}
def re_validate_certificate(request, course_key, generated_certificate):
"""
Remove certificate invalidation from db and start certificate generation task for this student.
Raises ValueError if certificate invalidation is present.
:param request: HttpRequest object
:param course_key: CourseKey object identifying the current course.
:param generated_certificate: GeneratedCertificate object of the student for the given course
"""
try:
# Fetch CertificateInvalidation object
certificate_invalidation = CertificateInvalidation.objects.get(generated_certificate=generated_certificate)
except ObjectDoesNotExist:
raise ValueError(_("Certificate Invalidation does not exist, Please refresh the page and try again."))
else:
# Deactivate certificate invalidation if it was fetched successfully.
certificate_invalidation.deactivate()
# We need to generate certificate only for a single student here
students = [certificate_invalidation.generated_certificate.user]
instructor_task.api.generate_certificates_for_students(request, course_key, students=students)
def validate_request_data_and_get_certificate(certificate_invalidation, course_key):
"""
Fetch and return GeneratedCertificate of the student passed in request data for the given course.
Raises ValueError in case of missing student username/email or
if student does not have certificate for the given course.
:param certificate_invalidation: dict containing certificate invalidation data
:param course_key: CourseKey object identifying the current course.
:return: GeneratedCertificate object of the student for the given course
"""
user = certificate_invalidation.get("user")
if not user:
raise ValueError(
_('Student username/email field is required and can not be empty. '
'Kindly fill in username/email and then press "Invalidate Certificate" button.')
)
student = get_student(user, course_key)
certificate = GeneratedCertificate.certificate_for_student(student, course_key)
if not certificate:
raise ValueError(_(
"The student {student} does not have certificate for the course {course}. Kindly verify student "
"username/email and the selected course are correct and try again."
).format(student=student.username, course=course_key.course))
return certificate
...@@ -161,4 +161,8 @@ urlpatterns = patterns( ...@@ -161,4 +161,8 @@ urlpatterns = patterns(
url(r'^generate_bulk_certificate_exceptions', url(r'^generate_bulk_certificate_exceptions',
'instructor.views.api.generate_bulk_certificate_exceptions', 'instructor.views.api.generate_bulk_certificate_exceptions',
name='generate_bulk_certificate_exceptions'), name='generate_bulk_certificate_exceptions'),
url(r'^certificate_invalidation_view/$',
'instructor.views.api.certificate_invalidation_view',
name='certificate_invalidation_view'),
) )
...@@ -43,6 +43,7 @@ from certificates.models import ( ...@@ -43,6 +43,7 @@ from certificates.models import (
GeneratedCertificate, GeneratedCertificate,
CertificateStatuses, CertificateStatuses,
CertificateGenerationHistory, CertificateGenerationHistory,
CertificateInvalidation,
) )
from certificates import api as certs_api from certificates import api as certs_api
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
...@@ -184,6 +185,13 @@ def instructor_dashboard_2(request, course_id): ...@@ -184,6 +185,13 @@ def instructor_dashboard_2(request, course_id):
kwargs={'course_id': unicode(course_key)} kwargs={'course_id': unicode(course_key)}
) )
certificate_invalidation_view_url = reverse( # pylint: disable=invalid-name
'certificate_invalidation_view',
kwargs={'course_id': unicode(course_key)}
)
certificate_invalidations = CertificateInvalidation.get_certificate_invalidations(course_key)
context = { context = {
'course': course, 'course': course,
'studio_url': get_studio_url(course, 'course'), 'studio_url': get_studio_url(course, 'course'),
...@@ -191,9 +199,11 @@ def instructor_dashboard_2(request, course_id): ...@@ -191,9 +199,11 @@ def instructor_dashboard_2(request, course_id):
'disable_buttons': disable_buttons, 'disable_buttons': disable_buttons,
'analytics_dashboard_message': analytics_dashboard_message, 'analytics_dashboard_message': analytics_dashboard_message,
'certificate_white_list': certificate_white_list, 'certificate_white_list': certificate_white_list,
'certificate_invalidations': certificate_invalidations,
'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, '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,
'certificate_invalidation_view_url': certificate_invalidation_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)
......
// 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);
\ No newline at end of file
// 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);
\ No newline at end of file
// 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);
\ No newline at end of file
// 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);
\ No newline at end of file
...@@ -655,6 +655,7 @@ ...@@ -655,6 +655,7 @@
'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js', '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/student_admin_spec.js',
'lms/include/js/spec/instructor_dashboard/certificates_exception_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_bulk_exception_spec.js',
'lms/include/js/spec/instructor_dashboard/certificates_spec.js', 'lms/include/js/spec/instructor_dashboard/certificates_spec.js',
'lms/include/js/spec/student_account/account_spec.js', 'lms/include/js/spec/student_account/account_spec.js',
......
...@@ -2159,16 +2159,18 @@ input[name="subject"] { ...@@ -2159,16 +2159,18 @@ input[name="subject"] {
} }
} }
.student-username-or-email {
width: 300px;
margin-bottom: 10px;
}
.notes-field {
width: 400px;
}
#certificate-white-list-editor { #certificate-white-list-editor {
padding-top: 5px; padding-top: 5px;
.certificate-exception-inputs { .certificate-exception-inputs {
.student-username-or-email {
width: 300px;
margin-bottom: 10px;
}
.notes-field {
width: 400px;
}
p + p { p + p {
margin-top: 5px; margin-top: 5px;
} }
...@@ -2178,7 +2180,7 @@ input[name="subject"] { ...@@ -2178,7 +2180,7 @@ input[name="subject"] {
} }
} }
.white-listed-students { .white-listed-students, .invalidation-history {
margin-top: 10px; margin-top: 10px;
padding-top: 5px; padding-top: 5px;
table { table {
......
<p class="under-heading info">
<%= gettext("To invalidate a certificate for a particular learner, add the username or email address below.") %>
</p>
<div class="add-certificate-invalidation">
<input class='student-username-or-email' id="certificate-invalidation-user" type="text" placeholder="<%= gettext('Username or email address') %>" aria-describedby='student-user-name-or-email-tip'>
<textarea class='notes-field' id="certificate-invalidation-notes" rows="10" placeholder="<%= gettext('Add notes about this learner') %>" aria-describedby='notes-field-tip'></textarea>
<br/>
<button type="button" class="btn-blue" id="invalidate-certificate"><%= gettext('Invalidate Certificate') %></button>
</div>
<div class="message hidden"></div>
<div class="invalidation-history">
<% if (certificate_invalidations.length === 0) { %>
<p><%- gettext("No results") %></p>
<% } else { %>
<table>
<thead>
<tr>
<th class='user-name'><%= gettext('Student') %></th>
<th class='user-name'><%= gettext('Invalidated By') %></th>
<th class='date'><%= gettext('Invalidated') %></th>
<th class='notes'><%= gettext('Notes') %></th>
<th class='action'><%= gettext('Action') %></th>
</tr>
</thead>
<tbody>
<% for (var i = 0; i < certificate_invalidations.length; i++) {
var certificate_invalidation = certificate_invalidations[i];
%>
<tr>
<td><%- certificate_invalidation.get("user") %></td>
<td><%- certificate_invalidation.get("invalidated_by") %></td>
<td><%- certificate_invalidation.get("created") %></td>
<td><%- certificate_invalidation.get("notes") %></td>
<td><button class='re-validate-certificate' data-cid='<%- certificate_invalidation.cid %>'><%- gettext("Remove from Invalidation Table") %></button></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>
...@@ -8,6 +8,10 @@ import json ...@@ -8,6 +8,10 @@ import json
CertificateWhitelistFactory('${json.dumps(certificate_white_list)}', "${generate_certificate_exceptions_url}", "${certificate_exception_view_url}", "${generate_bulk_certificate_exceptions_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>
<%static:require_module module_name="js/certificates/factories/certificate_invalidation_factory" class_name="CertificateInvalidationFactory">
CertificateInvalidationFactory('${json.dumps(certificate_invalidations)}', '${certificate_invalidation_view_url}');
</%static:require_module>
<%page args="section_data"/> <%page args="section_data"/>
<div class="certificates-wrapper"> <div class="certificates-wrapper">
...@@ -165,10 +169,25 @@ import json ...@@ -165,10 +169,25 @@ import json
<div class="certificate-exception-section"> <div class="certificate-exception-section">
<div id="certificate-white-list-editor"></div> <div id="certificate-white-list-editor"></div>
<div class="bulk-white-list-exception"></div> <div class="bulk-white-list-exception"></div>
<div class="white-listed-students" id="white-listed-students"></div> <div class="white-listed-students" id="white-listed-students">
<div class="ui-loading">
<span class="spin"><i class="icon fa fa-refresh" aria-hidden="true"></i></span> <span class="copy">${_('Loading')}</span>
</div>
</div>
<br/> <br/>
</div> </div>
<div class="no-pending-tasks-message"></div> <div class="no-pending-tasks-message"></div>
</div> </div>
<hr class="section-divider" />
<div class="certificate-invalidation-container">
<h2> ${_("Invalidate Certificates")} </h2>
<div id="certificate-invalidation">
<div class="ui-loading">
<span class="spin"><i class="icon fa fa-refresh" aria-hidden="true"></i></span> <span class="copy">${_('Loading')}</span>
</div>
</div>
</div>
</div> </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","certificate-bulk-white-list"]: % 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", "certificate-invalidation"]:
<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