diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 5e34632c81..a9b516b4a3 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -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 diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index d54773c6c9..a10efa03f0 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -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.') diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index d626a45ed0..f9d2aee23f 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -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 @@ -2708,6 +2708,43 @@ def start_certificate_generation(request, course_id): return JsonResponse(response_payload) +@ensure_csrf_cookie +@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 diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index be99ef4600..7565c4e671 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -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[^/]*)', 'instructor.views.api.create_certificate_exception', name='create_certificate_exception'), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index c94dc09820..8034d3d0cf 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -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} diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 602a8ab64a..703c54b4c1 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -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) diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index c823abd22e..19e9ef4586 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -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)) diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 6ac8a7bb94..712b30cc0b 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -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) diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 9cc34e4827..51a81887e5 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -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 + ) diff --git a/lms/static/js/instructor_dashboard/certificates.js b/lms/static/js/instructor_dashboard/certificates.js index a8712b697b..df5404f158 100644 --- a/lms/static/js/instructor_dashboard/certificates.js +++ b/lms/static/js/instructor_dashboard/certificates.js @@ -1,4 +1,5 @@ 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) { diff --git a/lms/static/js/spec/instructor_dashboard/certificates_spec.js b/lms/static/js/spec/instructor_dashboard/certificates_spec.js new file mode 100644 index 0000000000..9dd500fa1e --- /dev/null +++ b/lms/static/js/spec/instructor_dashboard/certificates_spec.js @@ -0,0 +1,116 @@ +/*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 = '

Regenerate Certificates

' + + '
' + + '

Select one or more certificate statuses ' + + ' below using your mouse and ctrl or command key.

' + + ' ' + + ' ' + + ' ' + + '
' + + '
'; + + 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); + }); + + }); + } +); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 06456ecd0c..bf5ee49a10 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -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', diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index f2cd5f61e2..744696f8c4 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -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; diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html index 897c5588a3..43f039308d 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificates.html +++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html @@ -92,6 +92,25 @@ import json %endif % endif +
+

+

${_("Regenerate Certificates")}

+
+

${_('Select one or more certificate statuses below using your mouse and ctrl or command key.')}

+ + + + +
+
+ +

${_("Certificate Exceptions")}