From cc8722d1c0a94ddece55fb3c6297f3f60b690694 Mon Sep 17 00:00:00 2001 From: Justin Hynes Date: Thu, 21 Sep 2023 17:55:08 +0000 Subject: [PATCH] feat: publish `CERTIFICATE_REVOKED` events to the event bus This PR adds the ability for the LMS to publish `CERTIFICATE_REVOKED` events to the Event Bus. There is also work in progress for Credentials to consume these events. --- lms/djangoapps/certificates/config.py | 13 +++ lms/djangoapps/certificates/models.py | 4 + lms/djangoapps/certificates/signals.py | 21 ++++- .../certificates/tests/test_signals.py | 82 +++++++++++++------ 4 files changed, 93 insertions(+), 27 deletions(-) diff --git a/lms/djangoapps/certificates/config.py b/lms/djangoapps/certificates/config.py index fa9b3f2f82..5de6a51265 100644 --- a/lms/djangoapps/certificates/config.py +++ b/lms/djangoapps/certificates/config.py @@ -28,3 +28,16 @@ AUTO_CERTIFICATE_GENERATION = WaffleSwitch(f"{WAFFLE_NAMESPACE}.auto_certificate # .. toggle_target_removal_date: 2023-07-31 # .. toggle_tickets: TODO SEND_CERTIFICATE_CREATED_SIGNAL = SettingToggle('SEND_CERTIFICATE_CREATED_SIGNAL', default=False, module_name=__name__) + + +# .. toggle_name: SEND_CERTIFICATE_REVOKED_SIGNAL +# .. toggle_implementation: SettingToggle +# .. toggle_default: False +# .. toggle_description: When True, the system will publish `CERTIFICATE_REVOKED` signals to the event bus. The +# `CERTIFICATE_REVOKED` signal is emit when a certificate has been awarded to a learner and the creation process has +# completed. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-09-15 +# .. toggle_target_removal_date: 2024-01-01 +# .. toggle_tickets: TODO +SEND_CERTIFICATE_REVOKED_SIGNAL = SettingToggle('SEND_CERTIFICATE_REVOKED_SIGNAL', default=False, module_name=__name__) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index feb387cb6a..7b5677a690 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -378,6 +378,10 @@ class GeneratedCertificate(models.Model): if not grade: grade = '' + # the grade can come through revocation as a float, so we must convert it to a string to be compatible with the + # `CERTIFICATE_REVOKED` event definition + elif isinstance(grade, float): + grade = str(grade) if not mode: mode = self.mode diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index 676c10fb9c..4d43f0ed49 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -11,7 +11,7 @@ from openedx_events.event_bus import get_producer from common.djangoapps.course_modes import api as modes_api from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.signals import ENROLLMENT_TRACK_UPDATED -from lms.djangoapps.certificates.config import SEND_CERTIFICATE_CREATED_SIGNAL +from lms.djangoapps.certificates.config import SEND_CERTIFICATE_CREATED_SIGNAL, SEND_CERTIFICATE_REVOKED_SIGNAL from lms.djangoapps.certificates.generation_handler import ( CertificateGenerationNotAllowed, generate_allowlist_certificate_task, @@ -32,7 +32,7 @@ from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_PASSED, LEARNER_NOW_VERIFIED ) -from openedx_events.learning.signals import CERTIFICATE_CREATED +from openedx_events.learning.signals import CERTIFICATE_CREATED, CERTIFICATE_REVOKED log = logging.getLogger(__name__) @@ -162,7 +162,7 @@ def _listen_for_enrollment_mode_change(sender, user, course_key, mode, **kwargs) @receiver(CERTIFICATE_CREATED) -def listen_for_certificate_created_event(sender, signal, **kwargs): +def listen_for_certificate_created_event(sender, signal, **kwargs): # pylint: disable=unused-argument """ Publish `CERTIFICATE_CREATED` events to the event bus. """ @@ -174,3 +174,18 @@ def listen_for_certificate_created_event(sender, signal, **kwargs): event_data={'certificate': kwargs['certificate']}, event_metadata=kwargs['metadata'] ) + + +@receiver(CERTIFICATE_REVOKED) +def listen_for_certificate_revoked_event(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Publish `CERTIFICATE_REVOKED` events to the event bus. + """ + if SEND_CERTIFICATE_REVOKED_SIGNAL.is_enabled(): + get_producer().send( + signal=CERTIFICATE_REVOKED, + topic='learning-certificate-lifecycle', + event_key_field='certificate.course.course_key', + event_data={'certificate': kwargs['certificate']}, + event_metadata=kwargs['metadata'] + ) diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index b5993eb623..4eb8cf27f4 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -21,13 +21,16 @@ from lms.djangoapps.certificates.models import ( CertificateGenerationConfiguration, GeneratedCertificate ) -from lms.djangoapps.certificates.signals import listen_for_certificate_created_event +from lms.djangoapps.certificates.signals import ( + listen_for_certificate_created_event, + listen_for_certificate_revoked_event +) from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.tests.utils import mock_passing_grade from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from openedx_events.data import EventsMetadata -from openedx_events.learning.signals import CERTIFICATE_CREATED +from openedx_events.learning.signals import CERTIFICATE_CREATED, CERTIFICATE_REVOKED from openedx_events.learning.data import CourseData, UserData, UserPersonalData, CertificateData @@ -458,22 +461,9 @@ class CertificateEventBusTests(ModuleStoreTestCase): mode='verified', ) - @override_settings(SEND_CERTIFICATE_CREATED_SIGNAL=False) - @mock.patch('lms.djangoapps.certificates.signals.get_producer', autospec=True) - def test_event_disabled(self, mock_producer): + def _create_event_data(self, event_type, certificate_status): """ - Test to verify that we do not push `CERTIFICATE_CREATED` events to the event bus if the - `SEND_CERTIFICATE_CREATED_SIGNAL` setting is disabled. - """ - listen_for_certificate_created_event(None, CERTIFICATE_CREATED) - mock_producer.assert_not_called() - - @override_settings(SEND_CERTIFICATE_CREATED_SIGNAL=True) - @mock.patch('lms.djangoapps.certificates.signals.get_producer', autospec=True) - def test_event_enabled(self, mock_producer): - """ - Test to verify that we push `CERTIFICATE_CREATED` events to the event bus if the - `SEND_CERTIFICATE_CREATED_SIGNAL` setting is enabled. + Utility function to create test data for unit tests. """ expected_course_data = CourseData(course_key=self.course.id) expected_user_data = UserData( @@ -490,12 +480,12 @@ class CertificateEventBusTests(ModuleStoreTestCase): course=expected_course_data, mode='verified', grade='', - current_status='downloadable', + current_status=certificate_status, download_url='', name='', ) - event_metadata = EventsMetadata( - event_type=CERTIFICATE_CREATED.event_type, + expected_event_metadata = EventsMetadata( + event_type=event_type.event_type, id=uuid4(), minorversion=0, source='openedx/lms/web', @@ -503,15 +493,59 @@ class CertificateEventBusTests(ModuleStoreTestCase): time=datetime.now(timezone.utc) ) - event_kwargs = { + return { 'certificate': expected_certificate_data, - 'metadata': event_metadata + 'metadata': expected_event_metadata, } - listen_for_certificate_created_event(None, CERTIFICATE_CREATED, **event_kwargs) + @override_settings(SEND_CERTIFICATE_CREATED_SIGNAL=False) + @mock.patch('lms.djangoapps.certificates.signals.get_producer', autospec=True) + def test_certificate_created_event_disabled(self, mock_producer): + """ + Test to verify that we do not publish `CERTIFICATE_CREATED` events to the event bus if the + `SEND_CERTIFICATE_CREATED_SIGNAL` setting is disabled. + """ + listen_for_certificate_created_event(None, CERTIFICATE_CREATED) + mock_producer.assert_not_called() + + @override_settings(SEND_CERTIFICATE_REVOKED_SIGNAL=False) + @mock.patch('lms.djangoapps.certificates.signals.get_producer', autospec=True) + def test_certificate_revoked_event_disabled(self, mock_producer): + """ + Test to verify that we do not publish `CERTIFICATE_REVOKED` events to the event bus if the + `SEND_CERTIFICATE_REVOKED_SIGNAL` setting is disabled. + """ + listen_for_certificate_created_event(None, CERTIFICATE_REVOKED) + mock_producer.assert_not_called() + + @override_settings(SEND_CERTIFICATE_CREATED_SIGNAL=True) + @mock.patch('lms.djangoapps.certificates.signals.get_producer', autospec=True) + def test_certificate_created_event_enabled(self, mock_producer): + """ + Test to verify that we push `CERTIFICATE_CREATED` events to the event bus if the + `SEND_CERTIFICATE_CREATED_SIGNAL` setting is enabled. + """ + event_data = self._create_event_data(CERTIFICATE_CREATED, CertificateStatuses.downloadable) + listen_for_certificate_created_event(None, CERTIFICATE_CREATED, **event_data) # verify that the data sent to the event bus matches what we expect data = mock_producer.return_value.send.call_args.kwargs assert data['signal'].event_type == CERTIFICATE_CREATED.event_type - assert data['event_data']['certificate'] == expected_certificate_data + assert data['event_data']['certificate'] == event_data['certificate'] + assert data['topic'] == 'learning-certificate-lifecycle' + assert data['event_key_field'] == 'certificate.course.course_key' + + @override_settings(SEND_CERTIFICATE_REVOKED_SIGNAL=True) + @mock.patch('lms.djangoapps.certificates.signals.get_producer', autospec=True) + def test_certificate_revoked_event_enabled(self, mock_producer): + """ + Test to verify that we push `CERTIFICATE_REVOKED` events to the event bus if the + `SEND_CERTIFICATE_REVOKED_SIGNAL` setting is enabled. + """ + event_data = self._create_event_data(CERTIFICATE_REVOKED, CertificateStatuses.notpassing) + listen_for_certificate_revoked_event(None, CERTIFICATE_REVOKED, **event_data) + # verify that the data sent to the event bus matches what we expect + data = mock_producer.return_value.send.call_args.kwargs + assert data['signal'].event_type == CERTIFICATE_REVOKED.event_type + assert data['event_data']['certificate'] == event_data['certificate'] assert data['topic'] == 'learning-certificate-lifecycle' assert data['event_key_field'] == 'certificate.course.course_key'