Commit 094ed321 by Saleem Latif

Added ability to regenerate certificates from Instructor Dashboard

parent 222bdd98
......@@ -54,6 +54,7 @@ import os
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import Count
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
......@@ -187,6 +188,31 @@ class GeneratedCertificate(models.Model):
return None
@classmethod
def get_unique_statuses(cls, course_key=None, flat=False):
"""
1 - Return unique statuses as a list of dictionaries containing the following key value pairs
[
{'status': 'status value from db', 'count': 'occurrence count of the status'},
{...},
..., ]
2 - if flat is 'True' then return unique statuses as a list
3 - if course_key is given then return unique statuses associated with the given course
:param course_key: Course Key identifier
:param flat: boolean showing whether to return statuses as a list of values or a list of dictionaries.
"""
query = cls.objects
if course_key:
query = query.filter(course_id=course_key)
if flat:
return query.values_list('status', flat=True).distinct()
else:
return query.values('status').annotate(count=Count('status'))
@receiver(post_save, sender=GeneratedCertificate)
def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=no-self-argument, unused-argument
......
......@@ -12,7 +12,8 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from config_models.models import cache
from courseware.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
from certificates.models import CertificateGenerationConfiguration
from certificates.tests.factories import GeneratedCertificateFactory
from certificates.models import CertificateGenerationConfiguration, CertificateStatuses
from certificates import api as certs_api
......@@ -486,3 +487,78 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertTrue(res_json['success'])
def test_certificate_regeneration_success(self):
"""
Test certificate regeneration is successful when accessed with 'certificate_statuses'
present in GeneratedCertificate table.
"""
# Create a generated Certificate of some user with status 'downloadable'
GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor'
)
# Login the client and access the url with 'certificate_statuses'
self.client.login(username=self.global_staff.username, password='test')
url = reverse('start_certificate_regeneration', kwargs={'course_id': unicode(self.course.id)})
response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.downloadable]})
# Assert 200 status code in response
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
# Assert request is successful
self.assertTrue(res_json['success'])
# Assert success message
self.assertEqual(
res_json['message'],
u'Certificate regeneration task has been started. You can view the status of the generation task in '
u'the "Pending Tasks" section.'
)
def test_certificate_regeneration_error(self):
"""
Test certificate regeneration errors out when accessed with either empty list of 'certificate_statuses' or
the 'certificate_statuses' that are not present in GeneratedCertificate table.
"""
# Create a dummy course and GeneratedCertificate with the same status as the one we will use to access
# 'start_certificate_regeneration' but their error message should be displayed as GeneratedCertificate
# belongs to a different course
dummy_course = CourseFactory.create()
GeneratedCertificateFactory.create(
user=self.user,
course_id=dummy_course.id,
status=CertificateStatuses.generating,
mode='honor'
)
# Login the client and access the url without 'certificate_statuses'
self.client.login(username=self.global_staff.username, password='test')
url = reverse('start_certificate_regeneration', kwargs={'course_id': unicode(self.course.id)})
response = self.client.post(url)
# Assert 400 status code in response
self.assertEqual(response.status_code, 400)
res_json = json.loads(response.content)
# Assert Error Message
self.assertEqual(
res_json['message'],
u'Please select one or more certificate statuses that require certificate regeneration.'
)
# Access the url passing 'certificate_statuses' that are not present in db
url = reverse('start_certificate_regeneration', kwargs={'course_id': unicode(self.course.id)})
response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.generating]})
# Assert 400 status code in response
self.assertEqual(response.status_code, 400)
res_json = json.loads(response.content)
# Assert Error Message
self.assertEqual(res_json['message'], u'Please select certificate statuses from the list only.')
......@@ -92,7 +92,7 @@ from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository
from certificates import api as certs_api
from certificates.models import CertificateWhitelist
from certificates.models import CertificateWhitelist, GeneratedCertificate
from bulk_email.models import CourseEmail
from student.models import get_user_by_username_or_email
......@@ -2712,6 +2712,43 @@ def start_certificate_generation(request, course_id):
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_global_staff
@require_POST
def start_certificate_regeneration(request, course_id):
"""
Start regenerating certificates for students whose certificate statuses lie with in 'certificate_statuses'
entry in POST data.
"""
course_key = CourseKey.from_string(course_id)
certificates_statuses = request.POST.getlist('certificate_statuses', [])
if not certificates_statuses:
return JsonResponse(
{'message': _('Please select one or more certificate statuses that require certificate regeneration.')},
status=400
)
# Check if the selected statuses are allowed
allowed_statuses = GeneratedCertificate.get_unique_statuses(course_key=course_key, flat=True)
if not set(certificates_statuses).issubset(allowed_statuses):
return JsonResponse(
{'message': _('Please select certificate statuses from the list only.')},
status=400
)
try:
instructor_task.api.regenerate_certificates(request, course_key, certificates_statuses)
except AlreadyRunningError as error:
return JsonResponse({'message': error.message}, status=400)
response_payload = {
'message': _('Certificate regeneration task has been started. '
'You can view the status of the generation task in the "Pending Tasks" section.'),
'success': True
}
return JsonResponse(response_payload)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_global_staff
@require_POST
def create_certificate_exception(request, course_id, white_list_student=None):
"""
Add Students to certificate white list.
......
......@@ -143,6 +143,10 @@ urlpatterns = patterns(
'instructor.views.api.start_certificate_generation',
name='start_certificate_generation'),
url(r'^start_certificate_regeneration',
'instructor.views.api.start_certificate_regeneration',
name='start_certificate_regeneration'),
url(r'^create_certificate_exception/(?P<white_list_student>[^/]*)',
'instructor.views.api.create_certificate_exception',
name='create_certificate_exception'),
......
......@@ -37,7 +37,7 @@ from student.models import CourseEnrollment
from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegCodeItem
from course_modes.models import CourseMode, CourseModesArchive
from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole
from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist
from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist, GeneratedCertificate
from certificates import api as certs_api
from util.date_utils import get_default_time_display
......@@ -299,6 +299,7 @@ def _section_certificates(course):
'enabled_for_course': certs_api.cert_generation_enabled(course.id),
'instructor_generation_enabled': instructor_generation_enabled,
'html_cert_enabled': html_cert_enabled,
'certificate_statuses': GeneratedCertificate.get_unique_statuses(course_key=course.id),
'urls': {
'generate_example_certificates': reverse(
'generate_example_certificates',
......@@ -312,6 +313,10 @@ def _section_certificates(course):
'start_certificate_generation',
kwargs={'course_id': course.id}
),
'start_certificate_regeneration': reverse(
'start_certificate_regeneration',
kwargs={'course_id': course.id}
),
'list_instructor_tasks_url': reverse(
'list_instructor_tasks',
kwargs={'course_id': course.id}
......
......@@ -512,3 +512,27 @@ def generate_certificates_for_students(request, course_key, students=None): # p
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
def regenerate_certificates(request, course_key, statuses_to_regenerate, students=None):
"""
Submits a task to regenerate certificates for given students enrolled in the course or
all students if argument 'students' is None.
Regenerate Certificate only if the status of the existing generated certificate is in 'statuses_to_regenerate'
list passed in the arguments.
Raises AlreadyRunningError if certificates are currently being generated.
"""
if students:
task_type = 'regenerate_certificates_certain_student'
students = [student.id for student in students]
task_input = {'students': students}
else:
task_type = 'regenerate_certificates_all_student'
task_input = {}
task_input.update({"statuses_to_regenerate": statuses_to_regenerate})
task_class = generate_certificates
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
......@@ -1414,7 +1414,8 @@ def generate_students_certificates(
current_step = {'step': 'Calculating students already have certificates'}
task_progress.update_task_state(extra_meta=current_step)
students_require_certs = students_require_certificate(course_id, enrolled_students)
statuses_to_regenerate = task_input.get('statuses_to_regenerate', [])
students_require_certs = students_require_certificate(course_id, enrolled_students, statuses_to_regenerate)
task_progress.skipped = task_progress.total - len(students_require_certs)
......@@ -1523,15 +1524,31 @@ def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, tas
return task_progress.update_task_state(extra_meta=current_step)
def students_require_certificate(course_id, enrolled_students):
""" Returns list of students where certificates needs to be generated.
Removing those students who have their certificate already generated
from total enrolled students for given course.
def students_require_certificate(course_id, enrolled_students, statuses_to_regenerate=None):
"""
Returns list of students where certificates needs to be generated.
if 'statuses_to_regenerate' is given then return students that have Generated Certificates
and the generated certificate status lies in 'statuses_to_regenerate'
if 'statuses_to_regenerate' is not given then return all the enrolled student skipping the ones
whose certificates have already been generated.
:param course_id:
:param enrolled_students:
:param statuses_to_regenerate:
"""
# compute those students where certificates already generated
students_already_have_certs = User.objects.filter(
~Q(generatedcertificate__status=CertificateStatuses.unavailable),
generatedcertificate__course_id=course_id)
return list(set(enrolled_students) - set(students_already_have_certs))
if statuses_to_regenerate:
# Return Students that have Generated Certificates and the generated certificate status
# lies in 'statuses_to_regenerate'
return User.objects.filter(
generatedcertificate__course_id=course_id,
generatedcertificate__status__in=statuses_to_regenerate
)
else:
# compute those students whose certificates are already generated
students_already_have_certs = User.objects.filter(
~Q(generatedcertificate__status=CertificateStatuses.unavailable),
generatedcertificate__course_id=course_id)
# Return all the enrolled student skipping the ones whose certificates have already been generated
return list(set(enrolled_students) - set(students_already_have_certs))
......@@ -22,6 +22,7 @@ from instructor_task.api import (
submit_executive_summary_report,
submit_course_survey_report,
generate_certificates_for_all_students,
regenerate_certificates
)
from instructor_task.api_helper import AlreadyRunningError
......@@ -31,6 +32,7 @@ from instructor_task.tests.test_base import (InstructorTaskTestCase,
InstructorTaskModuleTestCase,
TestReportMixin,
TEST_COURSE_KEY)
from certificates.models import CertificateStatuses
class InstructorTaskReportTest(InstructorTaskTestCase):
......@@ -263,3 +265,18 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
self.course.id
)
self._test_resubmission(api_call)
def test_regenerate_certificates(self):
"""
Tests certificates regeneration task submission api
"""
def api_call():
"""
wrapper method for regenerate_certificates
"""
return regenerate_certificates(
self.create_task_request(self.instructor),
self.course.id,
[CertificateStatuses.downloadable, CertificateStatuses.generating]
)
self._test_resubmission(api_call)
......@@ -1635,3 +1635,149 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
},
result
)
def test_certificate_regeneration_for_students(self):
"""
Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
certificate statuses lies in the list 'statuses_to_regenerate' given in task_input.
"""
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
# mark 2 students to have certificates generated already
for student in students[:2]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor'
)
# mark 3 students to have certificates generated with status 'error'
for student in students[2:5]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.error,
mode='honor'
)
# mark 6th students to have certificates generated with status 'deleted'
for student in students[5:6]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.deleted,
mode='honor'
)
# white-list 7 students
for student in students[:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
# Certificates should be regenerated for students having generated certificates with status
# 'downloadable' or 'error' which are total of 5 students in this test case
task_input = {'statuses_to_regenerate': [CertificateStatuses.downloadable, CertificateStatuses.error]}
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued")
result = generate_students_certificates(
None, None, self.course.id, task_input, 'certificates generated'
)
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 5,
'succeeded': 5,
'failed': 0,
'skipped': 5
},
result
)
def test_certificate_regeneration_with_expected_failures(self):
"""
Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
certificate statuses lies in the list 'statuses_to_regenerate' given in task_input.
"""
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
# mark 2 students to have certificates generated already
for student in students[:2]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor'
)
# mark 3 students to have certificates generated with status 'error'
for student in students[2:5]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.error,
mode='honor'
)
# mark 6th students to have certificates generated with status 'deleted'
for student in students[5:6]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.deleted,
mode='honor'
)
# mark rest of the 4 students with having generated certificates with status 'generating'
# These students are not added in white-list and they have not completed grades so certificate generation
# for these students should fail other than the one student that has been added to white-list
# so from these students 3 failures and 1 success
for student in students[6:]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.generating,
mode='honor'
)
# white-list 7 students
for student in students[:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
# Regenerated certificates for students having generated certificates with status
# 'deleted' or 'generating'
task_input = {'statuses_to_regenerate': [CertificateStatuses.deleted, CertificateStatuses.generating]}
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued")
result = generate_students_certificates(
None, None, self.course.id, task_input, 'certificates generated'
)
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 5,
'succeeded': 2,
'failed': 3,
'skipped': 5
},
result
)
var edx = edx || {};
var onCertificatesReady = null;
(function($, gettext, _) {
'use strict';
......@@ -6,7 +7,7 @@ var edx = edx || {};
edx.instructor_dashboard = edx.instructor_dashboard || {};
edx.instructor_dashboard.certificates = {};
$(function() {
onCertificatesReady = function() {
/**
* Show a confirmation message before letting staff members
* enable/disable self-generated certificates for a course.
......@@ -59,7 +60,52 @@ var edx = edx || {};
}
});
});
});
/**
* Start regenerating certificates for students.
*/
$section.on('click', '#btn-start-regenerating-certificates', function(event) {
if ( !confirm( gettext('Start regenerating certificates for students in this course?') ) ) {
event.preventDefault();
return;
}
var $btn_regenerating_certs = $(this),
$certificate_regeneration_status = $('.certificate-regeneration-status'),
url = $btn_regenerating_certs.data('endpoint');
$.ajax({
type: "POST",
data: $("#certificate-regenerating-form").serializeArray(),
url: url,
success: function (data) {
$btn_regenerating_certs.attr('disabled','disabled');
if(data.success){
$certificate_regeneration_status.text(data.message).
removeClass('msg-error').addClass('msg-success');
}
else{
$certificate_regeneration_status.text(data.message).
removeClass('msg-success').addClass("msg-error");
}
},
error: function(jqXHR) {
try{
var response = JSON.parse(jqXHR.responseText);
$certificate_regeneration_status.text(gettext(response.message)).
removeClass('msg-success').addClass("msg-error");
}catch(error){
$certificate_regeneration_status.
text(gettext('Error while regenerating certificates. Please try again.')).
removeClass('msg-success').addClass("msg-error");
}
}
});
});
};
// Call onCertificatesReady on document.ready event
$(onCertificatesReady);
var Certificates = (function() {
function Certificates($section) {
......
/*global define, onCertificatesReady */
define([
'jquery',
'common/js/spec_helpers/ajax_helpers',
'js/instructor_dashboard/certificates'
],
function($, AjaxHelpers) {
'use strict';
describe("edx.instructor_dashboard.certificates.regenerate_certificates", function() {
var $regenerate_certificates_button = null,
$certificate_regeneration_status = null,
requests = null;
var MESSAGES = {
success_message: 'Certificate regeneration task has been started. ' +
'You can view the status of the generation task in the "Pending Tasks" section.',
error_message: 'Please select one or more certificate statuses that require certificate regeneration.',
server_error_message: "Error while regenerating certificates. Please try again."
};
var expected = {
error_class: 'msg-error',
success_class: 'msg-success',
url: 'test/url/',
postData : [],
selected_statuses: ['downloadable', 'error'],
body: 'certificate_statuses=downloadable&certificate_statuses=error'
};
var select_options = function(option_values){
$.each(option_values, function(index, element){
$("#certificate-statuses option[value=" + element + "]").attr('selected', 'selected');
});
};
beforeEach(function() {
var fixture = '<section id = "certificates"><h2>Regenerate Certificates</h2>' +
'<form id="certificate-regenerating-form" method="post" action="' + expected.url + '">' +
' <p id="status-multi-select-tip">Select one or more certificate statuses ' +
' below using your mouse and ctrl or command key.</p>' +
' <select class="multi-select" multiple id="certificate-statuses" ' +
' name="certificate_statuses" aria-describedby="status-multi-select-tip">' +
' <option value="downloadable">Downloadable (2)</option>' +
' <option value="error">Error (2)</option>' +
' <option value="generating">Generating (1)</option>' +
' </select>' +
' <label for="certificate-statuses">' +
' Select certificate statuses that need regeneration and click Regenerate ' +
' Certificates button.' +
' </label>' +
' <input type="button" id="btn-start-regenerating-certificates" value="Regenerate Certificates"' +
' data-endpoint="' + expected.url + '"/>' +
'</form>' +
'<div class="message certificate-regeneration-status"></div></section>';
setFixtures(fixture);
onCertificatesReady();
$regenerate_certificates_button = $("#btn-start-regenerating-certificates");
$certificate_regeneration_status = $(".certificate-regeneration-status");
requests = AjaxHelpers.requests(this);
});
it("does not regenerate certificates if user cancels operation in confirm popup", function() {
spyOn(window, 'confirm').andReturn(false);
$regenerate_certificates_button.click();
expect(window.confirm).toHaveBeenCalled();
AjaxHelpers.expectNoRequests(requests);
});
it("sends regenerate certificates request if user accepts operation in confirm popup", function() {
spyOn(window, 'confirm').andReturn(true);
$regenerate_certificates_button.click();
expect(window.confirm).toHaveBeenCalled();
AjaxHelpers.expectRequest(requests, 'POST', expected.url);
});
it("sends regenerate certificates request with selected certificate statuses", function() {
spyOn(window, 'confirm').andReturn(true);
select_options(expected.selected_statuses);
$regenerate_certificates_button.click();
AjaxHelpers.expectRequest(requests, 'POST', expected.url, expected.body);
});
it("displays error message in case of server side error", function() {
spyOn(window, 'confirm').andReturn(true);
select_options(expected.selected_statuses);
$regenerate_certificates_button.click();
AjaxHelpers.respondWithError(requests, 500, {message: MESSAGES.server_error_message});
expect($certificate_regeneration_status).toHaveClass(expected.error_class);
expect($certificate_regeneration_status.text()).toEqual(MESSAGES.server_error_message);
});
it("displays error message returned by the server in case of unsuccessful request", function() {
spyOn(window, 'confirm').andReturn(true);
select_options(expected.selected_statuses);
$regenerate_certificates_button.click();
AjaxHelpers.respondWithError(requests, 400, {message: MESSAGES.error_message});
expect($certificate_regeneration_status).toHaveClass(expected.error_class);
expect($certificate_regeneration_status.text()).toEqual(MESSAGES.error_message);
});
it("displays success message returned by the server in case of successful request", function() {
spyOn(window, 'confirm').andReturn(true);
select_options(expected.selected_statuses);
$regenerate_certificates_button.click();
AjaxHelpers.respondWithJson(requests, {message: MESSAGES.success_message, success: true});
expect($certificate_regeneration_status).toHaveClass(expected.success_class);
expect($certificate_regeneration_status.text()).toEqual(MESSAGES.success_message);
});
});
}
);
......@@ -293,6 +293,10 @@
exports: 'coffee/src/instructor_dashboard/student_admin',
deps: ['jquery', 'underscore', 'coffee/src/instructor_dashboard/util', 'string_utils']
},
'js/instructor_dashboard/certificates': {
exports: 'js/instructor_dashboard/certificates',
deps: ['jquery', 'gettext', 'underscore']
},
// LMS class loaded explicitly until they are converted to use RequireJS
'js/student_account/account': {
exports: 'js/student_account/account',
......@@ -644,6 +648,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_spec.js',
'lms/include/js/spec/student_account/account_spec.js',
'lms/include/js/spec/student_account/access_spec.js',
'lms/include/js/spec/student_account/logistration_factory_spec.js',
......
......@@ -92,6 +92,20 @@
}
}
.msg-success{
border-top: 2px solid $confirm-color;
background: tint($confirm-color,95%);
color: $confirm-color;
}
.multi-select {
min-width: 150px;
option {
padding: ($baseline/5) $baseline ($baseline/10) ($baseline/4);
}
}
// inline copy
.copy-confirm {
color: $confirm-color;
......@@ -2125,12 +2139,6 @@ input[name="subject"] {
}
#certificate-white-list-editor{
.msg-success{
border-top: 2px solid $confirm-color;
background: tint($confirm-color,95%);
color: $confirm-color;
}
.certificate-exception-inputs{
.student-username-or-email{
width: 300px;
......
......@@ -92,6 +92,25 @@ import json
%endif
% endif
<hr>
<p class="start-certificate-regeneration">
<h2>${_("Regenerate Certificates")}</h2>
<form id="certificate-regenerating-form" method="post" action="${section_data['urls']['start_certificate_regeneration']}">
<p id='status-multi-select-tip'>${_('Select one or more certificate statuses below using your mouse and ctrl or command key.')}</p>
<select class="multi-select" multiple id="certificate-statuses" name="certificate_statuses" aria-describedby="status-multi-select-tip">
%for status in section_data['certificate_statuses']:
<option value="${status['status']}">${status['status'].title() + " ({})".format(status['count'])}</option>
%endfor
</select>
<label for="certificate-statuses">
${_("Select certificate statuses that need regeneration and click Regenerate Certificates button.")}
</label>
<input type="button" id="btn-start-regenerating-certificates" value="${_('Regenerate Certificates')}" data-endpoint="${section_data['urls']['start_certificate_regeneration']}"/>
</form>
<div class="message certificate-regeneration-status"></div>
</div>
<div class="certificate_exception-container">
<hr>
<h2> ${_("Certificate Exceptions")} </h2>
......
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