From 9aa0a01cae2bb52e337d66ef3e25e83f297a15b5 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Tue, 22 Dec 2015 18:34:19 +0500 Subject: [PATCH] Allow PMs to Invalidate Certificates --- .../pages/lms/instructor_dashboard.py | 62 +++- .../lms/test_lms_instructor_dashboard.py | 199 ++++++++++++ .../0007_certificateinvalidation.py | 30 ++ lms/djangoapps/certificates/models.py | 59 ++++ .../certificates/tests/factories.py | 11 +- .../instructor/tests/test_certificates.py | 301 +++++++++++++++++- lms/djangoapps/instructor/views/api.py | 194 ++++++++++- lms/djangoapps/instructor/views/api_urls.py | 4 + .../instructor/views/instructor_dashboard.py | 12 +- .../certificate_invalidation_collection.js | 16 + .../certificate_invalidation_factory.js | 31 ++ .../models/certificate_invalidation.js | 35 ++ .../views/certificate_invalidation_view.js | 121 +++++++ .../certificates_invalidation_spec.js | 268 ++++++++++++++++ lms/static/js/spec/main.js | 1 + .../sass/course/instructor/_instructor_2.scss | 18 +- .../certificate-invalidation.underscore | 43 +++ .../instructor_dashboard_2/certificates.html | 21 +- .../instructor_dashboard_2.html | 2 +- 19 files changed, 1399 insertions(+), 29 deletions(-) create mode 100644 lms/djangoapps/certificates/migrations/0007_certificateinvalidation.py create mode 100644 lms/static/js/certificates/collections/certificate_invalidation_collection.js create mode 100644 lms/static/js/certificates/factories/certificate_invalidation_factory.js create mode 100644 lms/static/js/certificates/models/certificate_invalidation.js create mode 100644 lms/static/js/certificates/views/certificate_invalidation_view.js create mode 100644 lms/static/js/spec/instructor_dashboard/certificates_invalidation_spec.js create mode 100644 lms/templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore diff --git a/common/test/acceptance/pages/lms/instructor_dashboard.py b/common/test/acceptance/pages/lms/instructor_dashboard.py index 7423761898..43e8eb9860 100644 --- a/common/test/acceptance/pages/lms/instructor_dashboard.py +++ b/common/test/acceptance/pages/lms/instructor_dashboard.py @@ -1006,6 +1006,16 @@ class CertificatesPage(PageObject): ) 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): """ Refresh Certificates Page and wait for the page to load completely. @@ -1064,6 +1074,42 @@ class CertificatesPage(PageObject): """ 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 def generate_certificates_button(self): """ @@ -1111,4 +1157,18 @@ class CertificatesPage(PageObject): """ 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') diff --git a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py index 1aa21b21ae..b5df4c85d1 100644 --- a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py +++ b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py @@ -845,3 +845,202 @@ class CertificatesTest(BaseInstructorDashboardTest): ' below to send the certificate.', self.certificates_section.message.text ) + + +@attr('shard_1') +class CertificateInvalidationTest(BaseInstructorDashboardTest): + """ + Tests for Certificates functionality on instructor dashboard. + """ + + @classmethod + def setUpClass(cls): + super(CertificateInvalidationTest, cls).setUpClass() + + # Create course fixture once each test run + CourseFixture( + org='test_org', + number='335535897951379478207964576572017930000', + run='test_run', + display_name='Test Course 335535897951379478207964576572017930000', + ).install() + + def setUp(self): + super(CertificateInvalidationTest, self).setUp() + # set same course number as we have in fixture json + self.course_info['number'] = "335535897951379478207964576572017930000" + + # we have created a user with this id in fixture, and created a generated certificate for it. + self.student_id = "99" + self.student_name = "testcert" + self.student_email = "cert@example.com" + + # Enroll above test user in the course + AutoAuthPage( + self.browser, + username=self.student_name, + email=self.student_email, + course_id=self.course_id, + ).visit() + + self.test_certificate_config = { + 'id': 1, + 'name': 'Certificate name', + 'description': 'Certificate description', + 'course_title': 'Course title override', + 'signatories': [], + 'version': 1, + 'is_active': True + } + + self.cert_fixture = CertificateConfigFixture(self.course_id, self.test_certificate_config) + self.cert_fixture.install() + self.user_name, self.user_id = self.log_in_as_instructor() + self.instructor_dashboard_page = self.visit_instructor_dashboard() + self.certificates_section = self.instructor_dashboard_page.select_certificates() + + disable_animations(self.certificates_section) + + def test_instructor_can_invalidate_certificate(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can add a certificate + invalidation to invalidation list. + + Given that I am on the Certificates tab on the Instructor Dashboard + When I fill in student username and notes fields and click 'Add Exception' button + Then new certificate exception should be visible in certificate exceptions list + """ + notes = 'Test Notes' + # Add a student to certificate invalidation list + self.certificates_section.add_certificate_invalidation(self.student_name, notes) + self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text) + self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text) + + # Validate success message + self.assertIn( + "Certificate has been successfully invalidated for {user}.".format(user=self.student_name), + self.certificates_section.certificate_invalidation_message.text + ) + + # Verify that added invalidations are also synced with backend + # Revisit Page + self.certificates_section.refresh() + + # wait for the certificate invalidations section to render + self.certificates_section.wait_for_certificate_invalidations_section() + + # validate certificate invalidation is visible in certificate invalidation list + self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text) + self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text) + + def test_instructor_can_re_validate_certificate(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can re-validate certificate. + + Given that I am on the certificates tab on the Instructor Dashboard + AND there is a certificate invalidation in certificate invalidation table + When I click "Remove from Invalidation Table" button + Then certificate is re-validated and removed from certificate invalidation table. + """ + notes = 'Test Notes' + # Add a student to certificate invalidation list + self.certificates_section.add_certificate_invalidation(self.student_name, notes) + self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text) + self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text) + + # Verify that added invalidations are also synced with backend + # Revisit Page + self.certificates_section.refresh() + + # wait for the certificate invalidations section to render + self.certificates_section.wait_for_certificate_invalidations_section() + + # click "Remove from Invalidation Table" button next to certificate invalidation + self.certificates_section.remove_first_certificate_invalidation() + + # validate certificate invalidation is removed from the list + self.assertNotIn(self.student_name, self.certificates_section.last_certificate_invalidation.text) + self.assertNotIn(notes, self.certificates_section.last_certificate_invalidation.text) + + self.assertIn( + "The certificate for this learner has been re-validated and the system is " + "re-running the grade for this learner.", + self.certificates_section.certificate_invalidation_message.text + ) + + def test_error_on_empty_user_name_or_email(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if he clicks + "Invalidate Certificate" button without entering student username or email. + + Given that I am on the certificates tab on the Instructor Dashboard + When I click "Invalidate Certificate" button without entering student username/email. + Then I see following error message + "Student username/email field is required and can not be empty." + "Kindly fill in username/email and then press "Invalidate Certificate" button." + """ + # Click "Invalidate Certificate" with empty student username/email field + self.certificates_section.fill_certificate_invalidation_user_name_field("") + self.certificates_section.click_invalidate_certificate_button() + self.certificates_section.wait_for_ajax() + + self.assertIn( + u'Student username/email field is required and can not be empty. ' + u'Kindly fill in username/email and then press "Invalidate Certificate" button.', + self.certificates_section.certificate_invalidation_message.text + ) + + def test_error_on_invalid_user(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if + the student entered for certificate invalidation does not exist. + + Given that I am on the certificates tab on the Instructor Dashboard + When I click "Invalidate Certificate" + AND the username entered does not exist in the system + Then I see following error message + "Student username/email field is required and can not be empty." + "Kindly fill in username/email and then press "Invalidate Certificate" button." + """ + invalid_user = "invalid_test_user" + # Click "Invalidate Certificate" with invalid student username/email + self.certificates_section.fill_certificate_invalidation_user_name_field(invalid_user) + self.certificates_section.click_invalidate_certificate_button() + self.certificates_section.wait_for_ajax() + + self.assertIn( + u"{user} does not exist in the LMS. Please check your spelling and retry.".format(user=invalid_user), + self.certificates_section.certificate_invalidation_message.text + ) + + def test_user_not_enrolled_error(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if + the student entered for certificate invalidation is not enrolled in the course. + + Given that I am on the certificates tab on the Instructor Dashboard + When I click "Invalidate Certificate" + AND the username entered is not enrolled in the current course + Then I see following error message + "{user} is not enrolled in this course. Please check your spelling and retry." + """ + new_user = 'test_user_{uuid}'.format(uuid=self.unique_id[6:12]) + new_email = 'test_user_{uuid}@example.com'.format(uuid=self.unique_id[6:12]) + # Create a new user who is not enrolled in the course + AutoAuthPage(self.browser, username=new_user, email=new_email).visit() + # Login as instructor and visit Certificate Section of Instructor Dashboard + self.user_name, self.user_id = self.log_in_as_instructor() + self.instructor_dashboard_page.visit() + self.certificates_section = self.instructor_dashboard_page.select_certificates() + + # Click 'Invalidate Certificate' button with not enrolled student + self.certificates_section.wait_for_certificate_invalidations_section() + + self.certificates_section.fill_certificate_invalidation_user_name_field(new_user) + self.certificates_section.click_invalidate_certificate_button() + self.certificates_section.wait_for_ajax() + + self.assertIn( + u"{user} is not enrolled in this course. Please check your spelling and retry.".format(user=new_user), + self.certificates_section.certificate_invalidation_message.text + ) diff --git a/lms/djangoapps/certificates/migrations/0007_certificateinvalidation.py b/lms/djangoapps/certificates/migrations/0007_certificateinvalidation.py new file mode 100644 index 0000000000..1dd5a49bba --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0007_certificateinvalidation.py @@ -0,0 +1,30 @@ +# -*- 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)), + ], + ), + ] diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 49abeb3bcb..273f8438cb 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -253,6 +253,12 @@ class GeneratedCertificate(models.Model): self.save() + def is_valid(self): + """ + Return True if certificate is valid else return False. + """ + return self.status == CertificateStatuses.downloadable + 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) +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) def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=unused-argument """ diff --git a/lms/djangoapps/certificates/tests/factories.py b/lms/djangoapps/certificates/tests/factories.py index 469dfeaabf..197d5efe2c 100644 --- a/lms/djangoapps/certificates/tests/factories.py +++ b/lms/djangoapps/certificates/tests/factories.py @@ -9,7 +9,7 @@ from student.models import LinkedInAddToProfileConfiguration from certificates.models import ( GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion, - BadgeImageConfiguration, + BadgeImageConfiguration, CertificateInvalidation, ) @@ -35,6 +35,15 @@ class CertificateWhitelistFactory(DjangoModelFactory): notes = 'Test Notes' +class CertificateInvalidationFactory(DjangoModelFactory): + + class Meta(object): + model = CertificateInvalidation + + notes = 'Test Notes' + active = True + + class BadgeAssertionFactory(DjangoModelFactory): class Meta(object): model = BadgeAssertion diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index 7f74b2b144..28cb59cc0c 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -13,9 +13,10 @@ 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.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory +from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory, \ + CertificateInvalidationFactory from certificates.models import CertificateGenerationConfiguration, CertificateStatuses, CertificateWhitelist, \ - GeneratedCertificate + GeneratedCertificate, CertificateInvalidation from certificates import api as certs_api from student.models import CourseEnrollment from django.core.files.uploadedfile import SimpleUploadedFile @@ -923,3 +924,299 @@ class TestCertificatesInstructorApiBulkWhiteListExceptions(SharedModuleStoreTest self.assertEqual(response.status_code, 200) data = json.loads(response.content) return data + + +@attr('shard_1') +@ddt.ddt +class CertificateInvalidationViewTests(SharedModuleStoreTestCase): + """ + Test certificate invalidation view. + """ + @classmethod + def setUpClass(cls): + super(CertificateInvalidationViewTests, cls).setUpClass() + cls.course = CourseFactory.create() + cls.url = reverse('certificate_invalidation_view', + kwargs={'course_id': cls.course.id}) + cls.notes = "Test notes." + + def setUp(self): + super(CertificateInvalidationViewTests, 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) + + self.generated_certificate = GeneratedCertificateFactory.create( + user=self.enrolled_user_1, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + mode='honor', + ) + + self.certificate_invalidation_data = dict( + user=self.enrolled_user_1.username, + notes=self.notes, + ) + + # Global staff can see the certificates section + self.client.login(username=self.global_staff.username, password="test") + + def test_invalidate_certificate(self): + """ + Test user can invalidate a generated certificate. + """ + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + ) + # Assert successful request processing + self.assertEqual(response.status_code, 200) + result = json.loads(response.content) + + # Assert Certificate Exception Updated data + self.assertEqual(result['user'], self.enrolled_user_1.username) + self.assertEqual(result['invalidated_by'], self.global_staff.username) + self.assertEqual(result['notes'], self.notes) + + # Verify that CertificateInvalidation record has been created in the database i.e. no DoesNotExist error + try: + CertificateInvalidation.objects.get( + generated_certificate=self.generated_certificate, + invalidated_by=self.global_staff, + notes=self.notes, + active=True, + ) + except ObjectDoesNotExist: + self.fail("The certificate is not invalidated.") + + # Validate generated certificate was invalidated + generated_certificate = GeneratedCertificate.objects.get( + user=self.enrolled_user_1, + course_id=self.course.id, + ) + self.assertFalse(generated_certificate.is_valid()) + + def test_missing_username_and_email_error(self): + """ + Test error message if user name or email is missing. + """ + self.certificate_invalidation_data.update({'user': ''}) + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + ) + + # 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'Student username/email field is required and can not be empty. ' + u'Kindly fill in username/email and then press "Invalidate Certificate" button.', + ) + + def test_invalid_user_name_error(self): + """ + Test error message if invalid user name is given. + """ + invalid_user = "test_invalid_user_name" + + self.certificate_invalidation_data.update({"user": invalid_user}) + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + ) + + # 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"{user} does not exist in the LMS. Please check your spelling and retry.".format(user=invalid_user), + ) + + def test_user_not_enrolled_error(self): + """ + Test error message if user is not enrolled in the course. + """ + self.certificate_invalidation_data.update({"user": self.not_enrolled_student.username}) + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + ) + + # 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"{user} is not enrolled in this course. Please check your spelling and retry.".format( + user=self.not_enrolled_student.username, + ), + ) + + def test_no_generated_certificate_error(self): + """ + Test error message if there is no generated certificate for the student. + """ + self.certificate_invalidation_data.update({"user": self.enrolled_user_2.username}) + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + ) + + # 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"The student {student} does not have certificate for the course {course}. " + u"Kindly verify student username/email and the selected course are correct and try again.".format( + student=self.enrolled_user_2.username, + course=self.course.number, + ), + ) + + def test_certificate_already_invalid_error(self): + """ + Test error message if certificate for the student is already invalid. + """ + # Invalidate user certificate + self.generated_certificate.invalidate() + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + ) + + # 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"Certificate for student {user} is already invalid, kindly verify that certificate " + u"was generated for this student and then proceed.".format( + user=self.enrolled_user_1.username, + ), + ) + + def test_duplicate_certificate_invalidation_error(self): + """ + Test error message if certificate invalidation for the student is already present. + """ + CertificateInvalidationFactory.create( + generated_certificate=self.generated_certificate, + invalidated_by=self.global_staff, + ) + # Invalidate user certificate + self.generated_certificate.invalidate() + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + ) + + # 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"Certificate of {user} has already been invalidated. Please check your spelling and retry.".format( + user=self.enrolled_user_1.username, + ), + ) + + def test_remove_certificate_invalidation(self): + """ + Test that user can remove certificate invalidation. + """ + # Invalidate user certificate + self.generated_certificate.invalidate() + + CertificateInvalidationFactory.create( + generated_certificate=self.generated_certificate, + invalidated_by=self.global_staff, + ) + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + REQUEST_METHOD='DELETE' + ) + + # Assert 204 status code in response + self.assertEqual(response.status_code, 204) + + # Verify that certificate invalidation successfully removed from database + with self.assertRaises(ObjectDoesNotExist): + CertificateInvalidation.objects.get( + generated_certificate=self.generated_certificate, + invalidated_by=self.global_staff, + active=True, + ) + + def test_remove_certificate_invalidation_error(self): + """ + Test error message if certificate invalidation does not exists. + """ + # Invalidate user certificate + self.generated_certificate.invalidate() + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_invalidation_data), + content_type='application/json', + REQUEST_METHOD='DELETE' + ) + + # 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"Certificate Invalidation does not exist, Please refresh the page and try again.", + ) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 1c16b3ce10..d52efa6c10 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -89,7 +89,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, GeneratedCertificate, CertificateStatuses +from certificates.models import CertificateWhitelist, GeneratedCertificate, CertificateStatuses, CertificateInvalidation from bulk_email.models import CourseEmail from student.models import get_user_by_username_or_email @@ -2839,28 +2839,55 @@ def parse_request_data_and_get_user(request, course_key): :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 """ - try: - 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.')) + certificate_exception = parse_request_data(request) user = certificate_exception.get('user_name', '') or certificate_exception.get('user_email', '') if not user: 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.')) - try: - db_user = get_user_by_username_or_email(user) - except ObjectDoesNotExist: - raise ValueError(_("{user} does not exist in the LMS. Please check your spelling and retry.").format(user=user)) - - # Make Sure the given student is enrolled in the course - if not CourseEnrollment.is_enrolled(db_user, course_key): - raise ValueError(_("{user} is not enrolled in this course. Please check your spelling and retry.") - .format(user=user)) + 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: + student = get_user_by_username_or_email(username_or_email) + except ObjectDoesNotExist: + 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 + if not CourseEnrollment.is_enrolled(student, course_key): + raise ValueError(_("{user} is not enrolled in this course. Please check your spelling and retry.") + .format(user=username_or_email)) + return student + + @transaction.non_atomic_requests @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -3014,3 +3041,142 @@ def generate_bulk_certificate_exceptions(request, course_id): # pylint: disable } 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 diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index da49d295da..0c9469b899 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -161,4 +161,8 @@ urlpatterns = patterns( url(r'^generate_bulk_certificate_exceptions', 'instructor.views.api.generate_bulk_certificate_exceptions', name='generate_bulk_certificate_exceptions'), + + url(r'^certificate_invalidation_view/$', + 'instructor.views.api.certificate_invalidation_view', + name='certificate_invalidation_view'), ) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 7186df11bf..f9acbd89c0 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -43,6 +43,7 @@ from certificates.models import ( GeneratedCertificate, CertificateStatuses, CertificateGenerationHistory, + CertificateInvalidation, ) from certificates import api as certs_api from util.date_utils import get_default_time_display @@ -184,6 +185,13 @@ def instructor_dashboard_2(request, course_id): 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 = { 'course': course, 'studio_url': get_studio_url(course, 'course'), @@ -191,9 +199,11 @@ def instructor_dashboard_2(request, course_id): 'disable_buttons': disable_buttons, 'analytics_dashboard_message': analytics_dashboard_message, 'certificate_white_list': certificate_white_list, + 'certificate_invalidations': certificate_invalidations, '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, + 'certificate_invalidation_view_url': certificate_invalidation_view_url, } return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context) diff --git a/lms/static/js/certificates/collections/certificate_invalidation_collection.js b/lms/static/js/certificates/collections/certificate_invalidation_collection.js new file mode 100644 index 0000000000..ddfaca25eb --- /dev/null +++ b/lms/static/js/certificates/collections/certificate_invalidation_collection.js @@ -0,0 +1,16 @@ +// Backbone.js Application Collection: CertificateInvalidationCollection +/*global define, RequireJS */ + +;(function(define) { + 'use strict'; + + define( + ['backbone', 'js/certificates/models/certificate_invalidation'], + + function(Backbone, CertificateInvalidation) { + return Backbone.Collection.extend({ + model: CertificateInvalidation + }); + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/certificates/factories/certificate_invalidation_factory.js b/lms/static/js/certificates/factories/certificate_invalidation_factory.js new file mode 100644 index 0000000000..99909cd687 --- /dev/null +++ b/lms/static/js/certificates/factories/certificate_invalidation_factory.js @@ -0,0 +1,31 @@ +// Backbone.js Page Object Factory: Certificate Invalidation Factory +/*global define, RequireJS */ + +;(function(define) { + 'use strict'; + define( + [ + 'js/certificates/views/certificate_invalidation_view', + 'js/certificates/collections/certificate_invalidation_collection' + ], + function(CertificateInvalidationView, CertificateInvalidationCollection) { + + return function(certificate_invalidation_collection_json, certificate_invalidation_url) { + var certificate_invalidation_collection = new CertificateInvalidationCollection( + JSON.parse(certificate_invalidation_collection_json), { + parse: true, + canBeEmpty: true, + url: certificate_invalidation_url + } + ); + + var certificate_invalidation_view = new CertificateInvalidationView({ + collection: certificate_invalidation_collection + }); + + certificate_invalidation_view.render(); + }; + + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/certificates/models/certificate_invalidation.js b/lms/static/js/certificates/models/certificate_invalidation.js new file mode 100644 index 0000000000..7c6ccb338a --- /dev/null +++ b/lms/static/js/certificates/models/certificate_invalidation.js @@ -0,0 +1,35 @@ +// Backbone.js Application Model: CertificateInvalidation +/*global define, RequireJS */ + +;(function(define) { + 'use strict'; + + define( + ['underscore', 'underscore.string', 'gettext', 'backbone'], + + function(_, str, gettext, Backbone) { + return Backbone.Model.extend({ + idAttribute: 'id', + + defaults: { + user: '', + invalidated_by: '', + created: '', + notes: '' + }, + + url: function() { + return this.get('url'); + }, + + validate: function(attrs) { + if (!_.str.trim(attrs.user)) { + // A username or email must be provided for certificate invalidation + return gettext('Student username/email field is required and can not be empty. ' + + 'Kindly fill in username/email and then press "Invalidate Certificate" button.'); + } + } + }); + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/certificates/views/certificate_invalidation_view.js b/lms/static/js/certificates/views/certificate_invalidation_view.js new file mode 100644 index 0000000000..3683bd149d --- /dev/null +++ b/lms/static/js/certificates/views/certificate_invalidation_view.js @@ -0,0 +1,121 @@ +// Backbone Application View: CertificateInvalidationView +/*global define, RequireJS */ + +;(function(define) { + 'use strict'; + define( + ['jquery', 'underscore', 'gettext', 'backbone', 'js/certificates/models/certificate_invalidation'], + + function($, _, gettext, Backbone, CertificateInvalidationModel) { + return Backbone.View.extend({ + el: "#certificate-invalidation", + messages: "div.message", + events: { + 'click #invalidate-certificate': 'invalidateCertificate', + 'click .re-validate-certificate': 'reValidateCertificate' + }, + + initialize: function() { + this.listenTo(this.collection, 'change add remove', this.render); + }, + + render: function() { + var template = this.loadTemplate('certificate-invalidation'); + this.$el.html(template({certificate_invalidations: this.collection.models})); + }, + + loadTemplate: function(name) { + var templateSelector = "#" + name + "-tpl", + templateText = $(templateSelector).text(); + return _.template(templateText); + }, + + invalidateCertificate: function() { + var user = this.$("#certificate-invalidation-user").val(); + var notes = this.$("#certificate-invalidation-notes").val(); + + var certificate_invalidation = new CertificateInvalidationModel({ + url: this.collection.url, + user: user, + notes: notes + }); + + if (this.collection.findWhere({user: user})) { + this.showMessage( + gettext("Certificate of ") + user + + gettext(" has already been invalidated. Please check your spelling and retry." + )); + } + else if (certificate_invalidation.isValid()) { + var self = this; + certificate_invalidation.save(null, { + wait: true, + + success: function(model) { + self.collection.add(model); + self.showMessage( + gettext('Certificate has been successfully invalidated for ') + user + '.' + ); + }, + + error: function(model, response) { + try { + var response_data = JSON.parse(response.responseText); + self.showMessage(response_data.message); + } + catch(exception) { + self.showMessage(gettext("Server Error, Please refresh the page and try again.")); + } + } + }); + + } + else { + this.showMessage(certificate_invalidation.validationError); + } + }, + + reValidateCertificate: function(event) { + var certificate_invalidation = $(event.target).data(); + var model = this.collection.get(certificate_invalidation), + self = this; + + if (model) { + model.destroy({ + success: function() { + self.showMessage(gettext('The certificate for this learner has been re-validated and ' + + 'the system is re-running the grade for this learner.')); + }, + error: function(model, response) { + try { + var response_data = JSON.parse(response.responseText); + self.showMessage(response_data.message); + } + catch(exception) { + self.showMessage(gettext("Server Error, Please refresh the page and try again.")); + } + }, + wait: true, + data: JSON.stringify(model.attributes) + }); + } + else { + self.showMessage(gettext('Could not find Certificate Invalidation in the list. ' + + 'Please refresh the page and try again')); + } + }, + + isEmailAddress: function validateEmail(email) { + var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i; + return re.test(email); + }, + + showMessage: function(message) { + $(this.messages + ">p" ).remove(); + this.$(this.messages).removeClass('hidden').append("

"+ gettext(message) + "

"); + } + + }); + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/spec/instructor_dashboard/certificates_invalidation_spec.js b/lms/static/js/spec/instructor_dashboard/certificates_invalidation_spec.js new file mode 100644 index 0000000000..e7c3a3d539 --- /dev/null +++ b/lms/static/js/spec/instructor_dashboard/certificates_invalidation_spec.js @@ -0,0 +1,268 @@ +/*global define */ +define([ + 'jquery', + 'common/js/spec_helpers/ajax_helpers', + 'js/certificates/models/certificate_invalidation', + 'js/certificates/views/certificate_invalidation_view', + 'js/certificates/collections/certificate_invalidation_collection' + ], + function($, AjaxHelpers, CertificateInvalidationModel, CertificateInvalidationView, + CertificateInvalidationCollection) { + 'use strict'; + describe("Field validation of invalidation model.", function() { + var certificate_invalidation = null; + var assertValid = function(fields, isValid, expectedErrors) { + certificate_invalidation.set(fields); + var errors = certificate_invalidation.validate(certificate_invalidation.attributes); + + if (isValid) { + expect(errors).toBe(undefined); + } else { + expect(errors).toEqual(expectedErrors); + } + }; + + var EXPECTED_ERRORS = { + user_name_or_email_required: 'Student username/email field is required and can not be empty. ' + + 'Kindly fill in username/email and then press "Invalidate Certificate" button.' + }; + + beforeEach(function() { + + certificate_invalidation = new CertificateInvalidationModel({user: 'test_user'}); + certificate_invalidation.set({ + notes: "Test notes" + }); + }); + + it("accepts valid email addresses", function() { + assertValid({user: "bob@example.com"}, true); + assertValid({user: "bob+smith@example.com"}, true); + assertValid({user: "bob+smith@example.com"}, true); + assertValid({user: "bob+smith@example.com"}, true); + assertValid({user: "bob@test.example.com"}, true); + assertValid({user: "bob@test-example.com"}, true); + }); + + it("displays username or email required error", function() { + assertValid({user: ""}, false, EXPECTED_ERRORS.user_name_or_email_required); + }); + }); + + describe("Certificate invalidation collection initialization and updates.", + function() { + var certificate_invalidations = null, + certificate_invalidation_url = 'test/url/'; + var certificate_invalidations_json = [ + { + id: 1, + user: "test1", + invalidated_by: 2, + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate invalidation" + }, + { + id: 2, + user: "test2", + invalidated_by: 2, + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate invalidation" + } + ]; + + beforeEach(function() { + certificate_invalidations = new CertificateInvalidationCollection(certificate_invalidations_json, { + parse: true, + canBeEmpty: true, + url: certificate_invalidation_url + }); + }); + + it("has 2 models in the collection after initialization", function() { + expect(certificate_invalidations.models.length).toEqual(2); + }); + + it("model is removed from collection on destroy", function() { + var model = certificate_invalidations.get({id: 2}); + model.destroy(); + expect(certificate_invalidations.models.length).toEqual(1); + expect(certificate_invalidations.get({id: 2})).toBe(undefined); + }); + } + ); + + describe("Certificate invalidation success/error messages on add/remove invalidations.", function() { + var view = null, + certificate_invalidation_url = 'test/url/', + user_name_field = null, + notes_field = null, + invalidate_button=null, + duplicate_user='test2', + new_user='test4@test.com', + requests=null; + + var messages = { + error: { + empty_user_name_email: 'Student username/email field is required and can not be empty. ' + + 'Kindly fill in username/email and then press "Invalidate Certificate" button.', + duplicate_user: "Certificate of " + (duplicate_user) + " has already been invalidated. " + + "Please check your spelling and retry.", + server_error: "Server Error, Please refresh the page and try again.", + from_server: "Test Message from server" + }, + success: { + saved: "Certificate has been successfully invalidated for " + new_user + '.', + re_validated: 'The certificate for this learner has been re-validated and ' + + 'the system is re-running the grade for this learner.' + } + }; + + var certificate_invalidations_json = [ + { + id: 1, + user: "test1", + invalidated_by: 2, + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate invalidation" + }, + { + id: 2, + user: "test2", + invalidated_by: 2, + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate invalidation" + } + ]; + + beforeEach(function() { + setFixtures(); + var fixture =readFixtures( + "templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore" + ); + + setFixtures( + "
" + + "

Invalidate Certificates

" + + "
" + + "
" + + "" + ); + + var certificate_invalidations = new CertificateInvalidationCollection(certificate_invalidations_json, { + parse: true, + canBeEmpty: true, + url: certificate_invalidation_url, + generate_certificates_url: certificate_invalidation_url + + }); + + view = new CertificateInvalidationView({collection: certificate_invalidations}); + view.render(); + + user_name_field = $("#certificate-invalidation-user"); + notes_field = $("#certificate-invalidation-notes"); + invalidate_button = $("#invalidate-certificate"); + + requests = AjaxHelpers.requests(this); + }); + + it("verifies view is initialized and rendered successfully", function() { + expect(view).not.toBe(undefined); + expect(view.$el.find('table tbody tr').length).toBe(2); + }); + + it("verifies view is rendered on add/remove to collection", function() { + var user = 'test3', + notes = 'test3 notes', + model = new CertificateInvalidationModel({user: user, notes: notes}); + + // Add another model in collection and verify it is rendered + view.collection.add(model); + expect(view.$el.find('table tbody tr').length).toBe(3); + + expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()). + toMatch(notes); + expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()). + toMatch(user); + + // Remove a model from collection + var collection_model = view.collection.get({id: 2}); + collection_model.destroy(); + + // Verify view is updated + expect(view.$el.find('table tbody tr').length).toBe(2); + + + }); + + it("verifies view error message on duplicate certificate validation.", function() { + $(user_name_field).val(duplicate_user); + $(invalidate_button).click(); + + expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.duplicate_user); + }); + + it("verifies view error message on empty username/email field.", function() { + $(user_name_field).val(""); + $(invalidate_button).click(); + + expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.empty_user_name_email); + }); + + it("verifies view success message on certificate invalidation.", function() { + $(user_name_field).val(new_user); + $(notes_field).val("test notes for user test4"); + $(invalidate_button).click(); + + AjaxHelpers.respondWithJson( + requests, + { + id: 4, + user: 'test4', + validated_by: 5, + created: "Thursday, December 29, 2015", + notes: "test notes for user test4" + } + ); + expect($("#certificate-invalidation div.message").text()).toEqual(messages.success.saved); + }); + + it("verifies view server error if server returns unknown response.", function() { + $(user_name_field).val(new_user); + $(notes_field).val("test notes for user test4"); + $(invalidate_button).click(); + + // Response with empty body + AjaxHelpers.respondWithTextError(requests, 400, ""); + + expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.server_error); + }); + + it("verifies certificate re-validation request and success message.", function() { + var user = 'test1', + re_validate_certificate = "div.certificate-invalidation-container table tr:contains('" + + user + "') td .re-validate-certificate"; + + $(re_validate_certificate).click(); + AjaxHelpers.respondWithJson(requests, {}); + + expect($("#certificate-invalidation div.message").text()).toEqual(messages.success.re_validated); + }); + + it("verifies error message from server is displayed.", function() { + var user = 'test1', + re_validate_certificate = "div.certificate-invalidation-container table tr:contains('" + + user + "') td .re-validate-certificate"; + + $(re_validate_certificate).click(); + AjaxHelpers.respondWithError(requests, 400, { + success: false, + message: messages.error.from_server + }); + + expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.from_server); + }); + + }); + } +); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 7ba4686fd3..bf49ffa4ad 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -655,6 +655,7 @@ 'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js', 'lms/include/js/spec/instructor_dashboard/student_admin_spec.js', 'lms/include/js/spec/instructor_dashboard/certificates_exception_spec.js', + 'lms/include/js/spec/instructor_dashboard/certificates_invalidation_spec.js', 'lms/include/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js', 'lms/include/js/spec/instructor_dashboard/certificates_spec.js', 'lms/include/js/spec/student_account/account_spec.js', diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 7c15906d75..f8c8dd5c09 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -2159,16 +2159,18 @@ input[name="subject"] { } } + .student-username-or-email { + width: 300px; + margin-bottom: 10px; + } + .notes-field { + width: 400px; + } + + #certificate-white-list-editor { padding-top: 5px; .certificate-exception-inputs { - .student-username-or-email { - width: 300px; - margin-bottom: 10px; - } - .notes-field { - width: 400px; - } p + p { margin-top: 5px; } @@ -2178,7 +2180,7 @@ input[name="subject"] { } } - .white-listed-students { + .white-listed-students, .invalidation-history { margin-top: 10px; padding-top: 5px; table { diff --git a/lms/templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore b/lms/templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore new file mode 100644 index 0000000000..c7de0cfd80 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore @@ -0,0 +1,43 @@ +

+ <%= gettext("To invalidate a certificate for a particular learner, add the username or email address below.") %> +

+ +
+ + +
+ +
+ + + +
+ <% if (certificate_invalidations.length === 0) { %> +

<%- gettext("No results") %>

+ <% } else { %> + + + + + + + + + + + + <% for (var i = 0; i < certificate_invalidations.length; i++) { + var certificate_invalidation = certificate_invalidations[i]; + %> + + + + + + + + <% } %> + +
<%= gettext('Student') %><%= gettext('Invalidated By') %><%= gettext('Invalidated') %><%= gettext('Notes') %><%= gettext('Action') %>
<%- certificate_invalidation.get("user") %><%- certificate_invalidation.get("invalidated_by") %><%- certificate_invalidation.get("created") %><%- certificate_invalidation.get("notes") %>
+ <% } %> +
diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html index 66f971d77b..2c03d7d825 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificates.html +++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html @@ -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}"); +<%static:require_module module_name="js/certificates/factories/certificate_invalidation_factory" class_name="CertificateInvalidationFactory"> + CertificateInvalidationFactory('${json.dumps(certificate_invalidations)}', '${certificate_invalidation_view_url}'); + + <%page args="section_data"/>
@@ -165,10 +169,25 @@ import json
-
+
+
+ ${_('Loading')} +
+

+
+ +
+

${_("Invalidate Certificates")}

+
+
+ ${_('Loading')} +
+
+
+ diff --git a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html index d04041a8dd..660ed2cd13 100644 --- a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html @@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse ## Include Underscore templates <%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"]: