diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 774647f295..c86147afa5 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -21,6 +21,7 @@ from util.organizations_helpers import get_course_organizations from certificates.models import ( CertificateGenerationConfiguration, CertificateGenerationCourseSetting, + CertificateInvalidation, CertificateStatuses, CertificateTemplate, CertificateTemplateAsset, @@ -273,6 +274,26 @@ def set_cert_generation_enabled(course_key, is_enabled): log.info(u"Disabled self-generated certificates for course '%s'.", unicode(course_key)) +def is_certificate_invalid(student, course_key): + """Check that whether the student in the course has been invalidated + for receiving certificates. + + Arguments: + student (user object): logged-in user + course_key (CourseKey): The course identifier. + + Returns: + Boolean denoting whether the student in the course is invalidated + to receive certificates + """ + is_invalid = False + certificate = GeneratedCertificate.certificate_for_student(student, course_key) + if certificate is not None: + is_invalid = CertificateInvalidation.has_certificate_invalidation(student, course_key) + + return is_invalid + + def cert_generation_enabled(course_key): """Check whether certificate generation is enabled for a course. diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 7fad232e0e..6aa10be8d7 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -436,6 +436,25 @@ class CertificateInvalidation(TimeStampedModel): }) return data + @classmethod + def has_certificate_invalidation(cls, student, course_key): + """Check that whether the student in the course has been invalidated + for receiving certificates. + + Arguments: + student (user): logged-in user + course_key (CourseKey): The course associated with the certificate. + + Returns: + Boolean denoting whether the student in the course is invalidated + to receive certificates + """ + return cls.objects.filter( + generated_certificate__course_id=course_key, + active=True, + generated_certificate__user=student + ).exists() + @receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) def handle_course_cert_awarded(sender, user, course_key, **kwargs): # pylint: disable=unused-argument diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index dc65f50ffe..996b18bfaf 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -13,6 +13,7 @@ from opaque_keys.edx.locator import CourseLocator from config_models.models import cache from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory +from courseware.tests.factories import GlobalStaffFactory from microsite_configuration import microsite from student.models import CourseEnrollment from student.tests.factories import UserFactory @@ -32,7 +33,10 @@ from certificates.models import ( certificate_status_for_student, ) from certificates.queue import XQueueCertInterface, XQueueAddToQueueError -from certificates.tests.factories import GeneratedCertificateFactory +from certificates.tests.factories import ( + CertificateInvalidationFactory, + GeneratedCertificateFactory +) FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() @@ -208,6 +212,118 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes ) +@attr('shard_1') +@ddt.ddt +class CertificateisInvalid(WebCertificateTestMixin, ModuleStoreTestCase): + """Tests for the `is_certificate_invalid` helper function. """ + + def setUp(self): + super(CertificateisInvalid, self).setUp() + + self.student = UserFactory() + self.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course' + ) + self.global_staff = GlobalStaffFactory() + self.request_factory = RequestFactory() + + def test_method_with_no_certificate(self): + """ Test the case when there is no certificate for a user for a specific course. """ + course = CourseFactory.create( + org='edx', + number='honor', + display_name='Course 1' + ) + # Also check query count for 'is_certificate_invalid' method. + with self.assertNumQueries(1): + self.assertFalse( + certs_api.is_certificate_invalid(self.student, course.id) + ) + + @ddt.data( + CertificateStatuses.generating, + CertificateStatuses.downloadable, + CertificateStatuses.notpassing, + CertificateStatuses.error, + CertificateStatuses.unverified, + CertificateStatuses.deleted, + CertificateStatuses.unavailable, + ) + def test_method_with_invalidated_cert(self, status): + """ Verify that if certificate is marked as invalid than method will return + True. """ + generated_cert = self._generate_cert(status) + self._invalidate_certificate(generated_cert, True) + self.assertTrue( + certs_api.is_certificate_invalid(self.student, self.course.id) + ) + + @ddt.data( + CertificateStatuses.generating, + CertificateStatuses.downloadable, + CertificateStatuses.notpassing, + CertificateStatuses.error, + CertificateStatuses.unverified, + CertificateStatuses.deleted, + CertificateStatuses.unavailable, + ) + def test_method_with_inactive_invalidated_cert(self, status): + """ Verify that if certificate is valid but it's invalidated status is + false than method will return false. """ + generated_cert = self._generate_cert(status) + self._invalidate_certificate(generated_cert, False) + self.assertFalse( + certs_api.is_certificate_invalid(self.student, self.course.id) + ) + + @ddt.data( + CertificateStatuses.generating, + CertificateStatuses.downloadable, + CertificateStatuses.notpassing, + CertificateStatuses.error, + CertificateStatuses.unverified, + CertificateStatuses.deleted, + CertificateStatuses.unavailable, + ) + def test_method_with_all_statues(self, status): + """ Verify method return True if certificate has valid status but it is + marked as invalid in CertificateInvalidation table. """ + + certificate = self._generate_cert(status) + CertificateInvalidationFactory.create( + generated_certificate=certificate, + invalidated_by=self.global_staff, + active=True + ) + # Also check query count for 'is_certificate_invalid' method. + with self.assertNumQueries(2): + self.assertTrue( + certs_api.is_certificate_invalid(self.student, self.course.id) + ) + + def _invalidate_certificate(self, certificate, active): + """ Dry method to mark certificate as invalid. """ + CertificateInvalidationFactory.create( + generated_certificate=certificate, + invalidated_by=self.global_staff, + active=active + ) + # Invalidate user certificate + certificate.invalidate() + self.assertFalse(certificate.is_valid()) + + def _generate_cert(self, status): + """ Dry method to generate certificate. """ + return GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=status, + mode='verified' + ) + + @attr('shard_1') class CertificateGetTests(SharedModuleStoreTestCase): """Tests for the `test_get_certificate_for_user` helper function. """ diff --git a/lms/djangoapps/certificates/tests/test_models.py b/lms/djangoapps/certificates/tests/test_models.py index 92f62f4026..9aad0b6771 100644 --- a/lms/djangoapps/certificates/tests/test_models.py +++ b/lms/djangoapps/certificates/tests/test_models.py @@ -15,11 +15,15 @@ from certificates.models import ( ExampleCertificateSet, CertificateHtmlViewConfiguration, CertificateTemplateAsset, + CertificateInvalidation, GeneratedCertificate, CertificateStatuses, CertificateGenerationHistory, ) -from certificates.tests.factories import GeneratedCertificateFactory +from certificates.tests.factories import ( + CertificateInvalidationFactory, + GeneratedCertificateFactory +) from instructor_task.tests.factories import InstructorTaskFactory from opaque_keys.edx.locator import CourseLocator from student.tests.factories import AdminFactory, UserFactory @@ -300,3 +304,50 @@ class TestCertificateGenerationHistory(TestCase): certificate_generation_history.get_task_name(), expected ) + + +@attr('shard_1') +class CertificateInvalidationTest(SharedModuleStoreTestCase): + """ + Test for the Certificate Invalidation model. + """ + + def setUp(self): + super(CertificateInvalidationTest, self).setUp() + self.course = CourseFactory() + self.user = UserFactory() + self.course_id = self.course.id # pylint: disable=no-member + self.certificate = GeneratedCertificateFactory.create( + status=CertificateStatuses.downloadable, + user=self.user, + course_id=self.course_id + ) + + def test_is_certificate_invalid_method(self): + """ Verify that method return false if certificate is valid. """ + + self.assertFalse( + CertificateInvalidation.has_certificate_invalidation(self.user, self.course_id) + ) + + def test_is_certificate_invalid_with_invalid_cert(self): + """ Verify that method return true if certificate is invalid. """ + + invalid_cert = CertificateInvalidationFactory.create( + generated_certificate=self.certificate, + invalidated_by=self.user + ) + # Invalidate user certificate + self.certificate.invalidate() + self.assertTrue( + CertificateInvalidation.has_certificate_invalidation(self.user, self.course_id) + ) + + # mark the entry as in-active. + invalid_cert.active = False + invalid_cert.save() + + # After making the certificate valid method will return false. + self.assertFalse( + CertificateInvalidation.has_certificate_invalidation(self.user, self.course_id) + ) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index b1a279d770..a85775bba9 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -32,7 +32,10 @@ import courseware.views.views as views import shoppingcart from certificates import api as certs_api from certificates.models import CertificateStatuses, CertificateGenerationConfiguration -from certificates.tests.factories import GeneratedCertificateFactory +from certificates.tests.factories import ( + CertificateInvalidationFactory, + GeneratedCertificateFactory +) from commerce.models import CommerceConfiguration from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory @@ -1387,6 +1390,89 @@ class ProgressPageTests(ModuleStoreTestCase): cert_button_hidden, 'Request Certificate' not in resp.content) + @patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) + @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75, 'section_breakdown': [], + 'grade_breakdown': []})) + def test_page_with_invalidated_certificate_with_html_view(self): + """ + Verify that for html certs if certificate is marked as invalidated than + re-generate button should not appear on progress page. + """ + generated_certificate = self.generate_certificate( + "http://www.example.com/certificate.pdf", "honor" + ) + CertificateGenerationConfiguration(enabled=True).save() + certs_api.set_cert_generation_enabled(self.course.id, True) + + # Course certificate configurations + certificates = [ + { + 'id': 1, + 'name': 'dummy', + 'description': 'dummy description', + 'course_title': 'dummy title', + 'signatories': [], + 'version': 1, + 'is_active': True + } + ] + self.course.certificates = {'certificates': certificates} + self.course.cert_html_view_enabled = True + self.course.save() + self.store.update_item(self.course, self.user.id) + + resp = self.client.get( + reverse('progress', args=[unicode(self.course.id)]) + ) + self.assertContains(resp, u"View Certificate") + self.assert_invalidate_certificate(generated_certificate) + + @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75, 'section_breakdown': [], + 'grade_breakdown': []})) + def test_page_with_invalidated_certificate_with_pdf(self): + """ + Verify that for pdf certs if certificate is marked as invalidated than + re-generate button should not appear on progress page. + """ + generated_certificate = self.generate_certificate( + "http://www.example.com/certificate.pdf", "honor" + ) + + CertificateGenerationConfiguration(enabled=True).save() + certs_api.set_cert_generation_enabled(self.course.id, True) + resp = self.client.get( + reverse('progress', args=[unicode(self.course.id)]) + ) + self.assertContains(resp, u'Download Your Certificate') + self.assert_invalidate_certificate(generated_certificate) + + def assert_invalidate_certificate(self, certificate): + """ Dry method to mark certificate as invalid. And assert the response. """ + CertificateInvalidationFactory.create( + generated_certificate=certificate, + invalidated_by=self.user + ) + # Invalidate user certificate + certificate.invalidate() + resp = self.client.get( + reverse('progress', args=[unicode(self.course.id)]) + ) + self.assertNotContains(resp, u'Request Certificate') + self.assertContains(resp, u'Your certificate has been invalidated.') + self.assertContains(resp, u'Please contact your course team if you have any questions.') + self.assertNotContains(resp, u'View Your Certificate') + self.assertNotContains(resp, u'Download Your Certificate') + + def generate_certificate(self, url, mode): + """ Dry method to generate certificate. """ + return GeneratedCertificateFactory.create( + user=self.user, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + download_url=url, + mode=mode + ) + @attr('shard_1') class VerifyCourseKeyDecoratorTests(TestCase): diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index eef21b84f7..698f49c1d6 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -753,10 +753,17 @@ def _progress(request, course_key, student_id): 'passed': is_course_passed(course, grade_summary), 'show_generate_cert_btn': show_generate_cert_btn, 'credit_course_requirements': _credit_course_requirements(course_key, student), - 'missing_required_verification': missing_required_verification + 'missing_required_verification': missing_required_verification, + 'certificate_invalidated': False, } if show_generate_cert_btn: + # If current certificate is invalidated by instructor + # then show the certificate invalidated message. + context.update({ + 'certificate_invalidated': certs_api.is_certificate_invalid(student, course_key) + }) + cert_status = certs_api.certificate_downloadable_status(student, course_key) context.update(cert_status) # showing the certificate web view button if feature flags are enabled. diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 503858e5e9..60977e2607 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -55,7 +55,10 @@ from django.utils.http import urlquote_plus