From 2e68ca23779b85e4adcaea877d03fa1b8e299613 Mon Sep 17 00:00:00 2001 From: Justin Hynes Date: Thu, 13 Apr 2023 19:23:04 +0000 Subject: [PATCH] feat: publish CERTIFICATE_CREATED events to the event bus [APER-2344] We would like to start consuming Certificate related events in Credentials from the event bus. This PR starts the process by publishing CERTIFICATE_CREATED events to the event bus. It also introduces a new feature flag (`SEND_CERTIFICATE_CREATED_SIGNAL`) to gate the functionality. --- lms/djangoapps/certificates/config.py | 15 +++- lms/djangoapps/certificates/models.py | 8 +- lms/djangoapps/certificates/signals.py | 18 ++++ .../certificates/tests/test_signals.py | 83 ++++++++++++++++++- 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/certificates/config.py b/lms/djangoapps/certificates/config.py index 05264e99c1..fa9b3f2f82 100644 --- a/lms/djangoapps/certificates/config.py +++ b/lms/djangoapps/certificates/config.py @@ -3,7 +3,7 @@ This module contains various configuration settings via waffle switches for the Certificates app. """ -from edx_toggles.toggles import WaffleSwitch +from edx_toggles.toggles import SettingToggle, WaffleSwitch # Namespace WAFFLE_NAMESPACE = 'certificates' @@ -15,3 +15,16 @@ WAFFLE_NAMESPACE = 'certificates' # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2017-09-14 AUTO_CERTIFICATE_GENERATION = WaffleSwitch(f"{WAFFLE_NAMESPACE}.auto_certificate_generation", __name__) + + +# .. toggle_name: SEND_CERTIFICATE_CREATED_SIGNAL +# .. toggle_implementation: SettingToggle +# .. toggle_default: False +# .. toggle_description: When True, the system will publish `CERTIFICATE_CREATED` signals to the event bus. The +# `CERTIFICATE_CREATED` 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-04-11 +# .. toggle_target_removal_date: 2023-07-31 +# .. toggle_tickets: TODO +SEND_CERTIFICATE_CREATED_SIGNAL = SettingToggle('SEND_CERTIFICATE_CREATED_SIGNAL', default=False, module_name=__name__) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index dc43b0fd35..feb387cb6a 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -2,7 +2,7 @@ Course certificates are created for a student and an offering of a course (a course run). """ - +from datetime import timezone import json import logging import os @@ -403,6 +403,7 @@ class GeneratedCertificate(models.Model): # .. event_implemented_name: CERTIFICATE_REVOKED CERTIFICATE_REVOKED.send_event( + time=self.modified_date.astimezone(timezone.utc), certificate=CertificateData( user=UserData( pii=UserPersonalData( @@ -473,6 +474,9 @@ class GeneratedCertificate(models.Model): Credentials IDA. """ super().save(*args, **kwargs) + + timestamp = self.modified_date.astimezone(timezone.utc) + COURSE_CERT_CHANGED.send_robust( sender=self.__class__, user=self.user, @@ -483,6 +487,7 @@ class GeneratedCertificate(models.Model): # .. event_implemented_name: CERTIFICATE_CHANGED CERTIFICATE_CHANGED.send_event( + time=timestamp, certificate=CertificateData( user=UserData( pii=UserPersonalData( @@ -515,6 +520,7 @@ class GeneratedCertificate(models.Model): # .. event_implemented_name: CERTIFICATE_CREATED CERTIFICATE_CREATED.send_event( + time=timestamp, certificate=CertificateData( user=UserData( pii=UserPersonalData( diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index 28e68e3402..76dcd11863 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -6,10 +6,12 @@ import logging from django.db.models.signals import post_save from django.dispatch import receiver +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.generation_handler import ( CertificateGenerationNotAllowed, generate_allowlist_certificate_task, @@ -30,6 +32,7 @@ from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_PASSED, LEARNER_NOW_VERIFIED ) +from openedx_events.learning.signals import CERTIFICATE_CREATED log = logging.getLogger(__name__) @@ -156,3 +159,18 @@ def _listen_for_enrollment_mode_change(sender, user, course_key, mode, **kwargs) course_key, ) return False + + +@receiver(CERTIFICATE_CREATED) +def listen_for_certificate_created_event(sender, signal, **kwargs): + """ + Publish `CERTIFICATE_CREATED` events to the event bus. + """ + if SEND_CERTIFICATE_CREATED_SIGNAL.is_enabled(): + get_producer().send( + signal=CERTIFICATE_CREATED, + topic='certificates', + 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 6b6ef54c02..90c888e2f2 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -3,10 +3,12 @@ Unit tests for enabling self-generated certificates for self-paced courses and disabling for instructor-paced courses. """ - +from datetime import datetime, timezone from unittest import mock +from uuid import uuid4 import ddt +from django.test.utils import override_settings from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -19,10 +21,14 @@ from lms.djangoapps.certificates.models import ( CertificateGenerationConfiguration, GeneratedCertificate ) +from lms.djangoapps.certificates.signals import listen_for_certificate_created_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.data import CourseData, UserData, UserPersonalData, CertificateData class SelfGeneratedCertsSignalTest(ModuleStoreTestCase): @@ -434,3 +440,78 @@ class EnrollmentModeChangeCertsTest(ModuleStoreTestCase): ) as mock_allowlist_task: self.verified_enrollment.change_mode('audit') mock_allowlist_task.assert_not_called() + + +class CertificateEventBusTests(ModuleStoreTestCase): + """ + Tests for Certificate events that interact with the event bus. + """ + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.name = f'{self.user.first_name} {self.user.last_name}' + self.course = CourseFactory.create(self_paced=True) + self.enrollment = CourseEnrollmentFactory( + user=self.user, + course_id=self.course.id, + is_active=True, + 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): + """ + 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. + """ + expected_course_data = CourseData(course_key=self.course.id) + expected_user_data = UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + name=self.name, + ), + id=self.user.id, + is_active=self.user.is_active + ) + expected_certificate_data = CertificateData( + user=expected_user_data, + course=expected_course_data, + mode='verified', + grade='', + current_status='downloadable', + download_url='', + name='', + ) + event_metadata = EventsMetadata( + event_type=CERTIFICATE_CREATED.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + event_kwargs = { + 'certificate': expected_certificate_data, + 'metadata': event_metadata + } + + listen_for_certificate_created_event(None, CERTIFICATE_CREATED, **event_kwargs) + # 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['topic'] == 'certificates' + assert data['event_key_field'] == 'certificate.course.course_key'