From a85da6d68e488fadf9975a2015220dfb2d87074c Mon Sep 17 00:00:00 2001 From: andrey-canon Date: Thu, 3 Aug 2023 15:23:19 -0500 Subject: [PATCH] feat: add create external certificate task https://edunext.atlassian.net/browse/FUTUREX-481 --- .../edxapp_wrapper/backends/student_m_v1.py | 11 ++ eox_nelp/edxapp_wrapper/student.py | 1 + .../test_backends/student_m_v1.py | 8 + eox_nelp/settings/test.py | 2 + eox_nelp/signals/receivers.py | 15 +- eox_nelp/signals/tasks.py | 32 ++-- eox_nelp/signals/tests/test_receivers.py | 180 +++++++++++++++++- eox_nelp/signals/tests/test_tasks.py | 44 +++-- eox_nelp/signals/tests/test_utils.py | 105 ++++++++++ eox_nelp/signals/utils.py | 60 ++++++ 10 files changed, 418 insertions(+), 40 deletions(-) create mode 100644 eox_nelp/signals/tests/test_utils.py create mode 100644 eox_nelp/signals/utils.py diff --git a/eox_nelp/edxapp_wrapper/backends/student_m_v1.py b/eox_nelp/edxapp_wrapper/backends/student_m_v1.py index a08dfb03..57ee544e 100644 --- a/eox_nelp/edxapp_wrapper/backends/student_m_v1.py +++ b/eox_nelp/edxapp_wrapper/backends/student_m_v1.py @@ -2,6 +2,7 @@ This file contains all the necessary student dependencies from https://github.com/eduNEXT/edunext-platform/tree/ednx-release/mango.master/common/djangoapps/student """ +from common.djangoapps.student import models_api as student_api # pylint: disable=import-error from common.djangoapps.student.models import ( # pylint: disable=import-error CourseAccessRole, CourseEnrollment, @@ -34,3 +35,13 @@ def get_user_profile_model(): UserProfile Model. """ return UserProfile + + +def get_student_api(): + """Allow to get the student_api module from + https://github.com/eduNEXT/edunext-platform/blob/ednx-release/mango.master/common/djangoapps/student/models_api.py + + Returns: + models_api module. + """ + return student_api diff --git a/eox_nelp/edxapp_wrapper/student.py b/eox_nelp/edxapp_wrapper/student.py index 7b8a0ef2..df85da10 100644 --- a/eox_nelp/edxapp_wrapper/student.py +++ b/eox_nelp/edxapp_wrapper/student.py @@ -13,3 +13,4 @@ CourseEnrollment = backend.get_course_enrollment_model() CourseAccessRole = backend.get_course_access_role_model() UserProfile = backend.get_user_profile_model() +student_api = backend.get_student_api() diff --git a/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py b/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py index 526961cb..f45af36f 100644 --- a/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py +++ b/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py @@ -24,3 +24,11 @@ def get_user_profile_model(): Mock class. """ return Mock() + + +def get_student_api(): + """Return test Module. + Returns: + Mock class. + """ + return Mock() diff --git a/eox_nelp/settings/test.py b/eox_nelp/settings/test.py index 836533a6..97618f2c 100644 --- a/eox_nelp/settings/test.py +++ b/eox_nelp/settings/test.py @@ -39,6 +39,7 @@ def plugin_settings(settings): # pylint: disable=function-redefined settings.EXTERNAL_CERTIFICATES_API_URL = 'https://testing.com' settings.EXTERNAL_CERTIFICATES_USER = 'test-user' settings.EXTERNAL_CERTIFICATES_PASSWORD = 'test-password' + settings.ENABLE_CERTIFICATE_PUBLISHER = True SETTINGS = SettingsClass() @@ -97,6 +98,7 @@ def plugin_settings(settings): # pylint: disable=function-redefined EOX_CORE_COURSEWARE_BACKEND = "eox_nelp.edxapp_wrapper.test_backends.courseware_m_v1" EOX_CORE_GRADES_BACKEND = "eox_nelp.edxapp_wrapper.test_backends.grades_m_v1" +EOX_CORE_CERTIFICATES_BACKEND = "eox_core.edxapp_wrapper.backends.certificates_h_v1_test" GET_SITE_CONFIGURATION_MODULE = 'eox_tenant.edxapp_wrapper.backends.site_configuration_module_test_v1' GET_THEMING_HELPERS = 'eox_tenant.edxapp_wrapper.backends.theming_helpers_test_v1' diff --git a/eox_nelp/signals/receivers.py b/eox_nelp/signals/receivers.py index 226fbfa6..15cd7676 100644 --- a/eox_nelp/signals/receivers.py +++ b/eox_nelp/signals/receivers.py @@ -13,7 +13,8 @@ from django.conf import settings from eox_nelp.notifications.tasks import create_course_notifications as create_course_notifications_task -from eox_nelp.signals.tasks import dispatch_futurex_progress +from eox_nelp.signals.tasks import create_external_certificate, dispatch_futurex_progress +from eox_nelp.signals.utils import _generate_external_certificate_data LOGGER = logging.getLogger(__name__) @@ -63,7 +64,7 @@ def create_course_notifications(course_key, **kwargs): # pylint: disable=unused create_course_notifications_task.delay(course_id=str(course_key)) -def certificate_publisher(certificate, **kwargs): # pylint: disable=unused-argument +def certificate_publisher(certificate, metadata, **kwargs): # pylint: disable=unused-argument """ Receiver that is connected to the CERTIFICATE_CREATED signal from 'openedx_events.learning.signals'. @@ -78,6 +79,8 @@ def certificate_publisher(certificate, **kwargs): # pylint: disable=unused-argu certificate: This an instance of the class defined in this link https://github.com/eduNEXT/openedx-events/blob/main/openedx_events/learning/data.py#L100 and will provide of the user certificate data. + metadata : Instance of the class defined in this link + https://github.com/eduNEXT/openedx-events/blob/main/openedx_events/data.py#L29 """ if not getattr(settings, "ENABLE_CERTIFICATE_PUBLISHER", False): return @@ -95,9 +98,15 @@ def certificate_publisher(certificate, **kwargs): # pylint: disable=unused-argu certificate.user.pii.username, certificate.course.course_key, ) + create_external_certificate.delay( + external_certificate_data=_generate_external_certificate_data( + timestamp=metadata.time, + certificate_data=certificate, + ) + ) else: LOGGER.info( - "The %s certificate associated with the user <%s> and course <%s>" + "The %s certificate associated with the user <%s> and course <%s> " "doesn't have a valid mode and therefore its data won't be published.", certificate.mode, certificate.user.pii.username, diff --git a/eox_nelp/signals/tasks.py b/eox_nelp/signals/tasks.py index 9d9af706..e419975f 100644 --- a/eox_nelp/signals/tasks.py +++ b/eox_nelp/signals/tasks.py @@ -12,31 +12,17 @@ from django.db.models import Q from eox_core.edxapp_wrapper.courseware import get_courseware_courses from eox_core.edxapp_wrapper.enrollments import get_enrollment -from eox_core.edxapp_wrapper.grades import get_course_grade_factory from opaque_keys.edx.keys import CourseKey +from eox_nelp.api_clients.certificates import ExternalCertificatesApiClient from eox_nelp.api_clients.futurex import FuturexApiClient from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview +from eox_nelp.signals.utils import _user_has_passing_grade courses = get_courseware_courses() -CourseGradeFactory = get_course_grade_factory() logger = logging.getLogger(__name__) -def _user_has_passing_grade(user, course_id): - """Determines if a user has passed a course based on the grading policies. - - Args: - user: Instace of Django User model. - course_id: Unique course identifier. - Returns: - course_grade.passed: True if the user has passed the course, otherwise False - """ - course_grade = CourseGradeFactory().read(user, course_key=CourseKey.from_string(course_id)) - - return course_grade.passed - - @shared_task def dispatch_futurex_progress(course_id, user_id, is_complete=None): """Dispatch the course progress of a user to Futurex platform. @@ -148,3 +134,17 @@ def _generate_progress_enrollment_data(user, course_id, user_has_passing_grade): progress_enrollment_data, ) return progress_enrollment_data + + +@shared_task +def create_external_certificate(external_certificate_data): + """This will create an external NELP certificate base on the input data + + Args: + timestamp: Date when the certificate was created. + certificate: This an instance of the class defined in this link + https://github.com/eduNEXT/openedx-events/blob/main/openedx_events/learning/data.py#L100 + and will provide of the user certificate data. + """ + api_client = ExternalCertificatesApiClient() + api_client.create_external_certificate(external_certificate_data) diff --git a/eox_nelp/signals/tests/test_receivers.py b/eox_nelp/signals/tests/test_receivers.py index 19528f47..a40003db 100644 --- a/eox_nelp/signals/tests/test_receivers.py +++ b/eox_nelp/signals/tests/test_receivers.py @@ -6,11 +6,19 @@ import unittest from django.contrib.auth import get_user_model +from django.test import override_settings from mock import patch from opaque_keys.edx.keys import CourseKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import CertificateData, CourseData, UserData, UserPersonalData from eox_nelp.edxapp_wrapper.test_backends import create_test_model -from eox_nelp.signals.receivers import block_completion_progress_publisher, course_grade_changed_progress_publisher +from eox_nelp.signals import receivers +from eox_nelp.signals.receivers import ( + block_completion_progress_publisher, + certificate_publisher, + course_grade_changed_progress_publisher, +) User = get_user_model() @@ -64,3 +72,173 @@ def test_call_dispatch(self, dispatch_mock): course_id=str(course_key), user_id=13, ) + + +class CertificatePublisherTestCase(unittest.TestCase): + """Test class for certificate_publisher.""" + + def setUp(self): + """Setup common conditions for every test case""" + self.username = "Harry" + self.course_key = CourseKey.from_string("course-v1:test+Cx105+2022_T4") + self.certificate_data = CertificateData( + user=UserData( + pii=UserPersonalData( + username=self.username, + email="harry@potter.com", + name="Harry Potter", + ), + id=10, + is_active=True, + ), + course=CourseData( + course_key=self.course_key, + ), + mode="no-id-professional", + grade=5, + current_status="downloadable", + download_url="", + name="", + ) + self.metadata = EventsMetadata( + event_type="org.openedx.learning.certificate.created.v1", + minorversion=0, + ) + + @override_settings(ENABLE_CERTIFICATE_PUBLISHER=False) + @patch("eox_nelp.signals.receivers.create_external_certificate") + def test_inactive_behavior(self, mock_task): + """Test that the asynchronous task wont' be called when the setting is not active. + + Expected behavior: + - create_external_certificate is not called + """ + certificate_publisher(self.certificate_data, self.metadata) + + mock_task.delay.assert_not_called() + + @patch("eox_nelp.signals.receivers.create_external_certificate") + def test_invalid_mode(self, mock_task): + """Test when the certificate data has an invalid mode. + + Expected behavior: + - create_external_certificate is not called. + - Invalid error was logged. + """ + invalid_mode = "audit" + log_info = ( + f"The {invalid_mode} certificate associated with the user <{self.username}> and course <{self.course_key}> " + "doesn't have a valid mode and therefore its data won't be published." + ) + + certificate_data = CertificateData( + user=UserData( + pii=UserPersonalData( + username=self.username, + email="harry@potter.com", + name="Harry Potter", + ), + id=10, + is_active=True, + ), + course=CourseData( + course_key=self.course_key, + ), + mode=invalid_mode, + grade=5, + current_status="downloadable", + download_url="", + name="", + ) + + with self.assertLogs(receivers.__name__, level="INFO") as logs: + certificate_publisher(certificate_data, self.metadata) + + mock_task.delay.assert_not_called() + self.assertEqual(logs.output, [ + f"INFO:{receivers.__name__}:{log_info}" + ]) + + @patch("eox_nelp.signals.receivers._generate_external_certificate_data") + @patch("eox_nelp.signals.receivers.create_external_certificate") + def test_create_call(self, mock_task, generate_data_mock): + """Test when the certificate mode is valid and the asynchronous task is called + + Expected behavior: + - _generate_external_certificate_data is called with the right parameters. + - create_external_certificate is called with the _generate_external_certificate_data output. + - Info was logged. + """ + generate_data_mock.return_value = { + "test": True, + } + log_info = ( + f"The no-id-professional certificate associated with the user <{self.username}> and " + f"course <{self.course_key}> has been already generated and its data will be sent " + "to the NELC certificate service." + ) + + with self.assertLogs(receivers.__name__, level="INFO") as logs: + certificate_publisher(self.certificate_data, self.metadata) + + generate_data_mock.assert_called_with( + timestamp=self.metadata.time, + certificate_data=self.certificate_data, + ) + mock_task.delay.assert_called_with( + external_certificate_data=generate_data_mock() + ) + self.assertEqual(logs.output, [ + f"INFO:{receivers.__name__}:{log_info}" + ]) + + @override_settings(CERTIFICATE_PUBLISHER_VALID_MODES=["another-mode"]) + @patch("eox_nelp.signals.receivers._generate_external_certificate_data") + @patch("eox_nelp.signals.receivers.create_external_certificate") + def test_alternative_mode(self, mock_task, generate_data_mock): + """Test when the certificate data has an alternative mode. + + Expected behavior: + - _generate_external_certificate_data is called with the right parameters. + - create_external_certificate is called with the _generate_external_certificate_data output. + - Info was logged. + """ + alternative_mode = "another-mode" + log_info = ( + f"The {alternative_mode} certificate associated with the user <{self.username}> and " + f"course <{self.course_key}> has been already generated and its data will be sent " + "to the NELC certificate service." + ) + certificate_data = CertificateData( + user=UserData( + pii=UserPersonalData( + username=self.username, + email="harry@potter.com", + name="Harry Potter", + ), + id=10, + is_active=True, + ), + course=CourseData( + course_key=self.course_key, + ), + mode=alternative_mode, + grade=5, + current_status="downloadable", + download_url="", + name="", + ) + + with self.assertLogs(receivers.__name__, level="INFO") as logs: + certificate_publisher(certificate_data, self.metadata) + + generate_data_mock.assert_called_with( + timestamp=self.metadata.time, + certificate_data=certificate_data, + ) + mock_task.delay.assert_called_with( + external_certificate_data=generate_data_mock() + ) + self.assertEqual(logs.output, [ + f"INFO:{receivers.__name__}:{log_info}" + ]) diff --git a/eox_nelp/signals/tests/test_tasks.py b/eox_nelp/signals/tests/test_tasks.py index 04c03136..da3ef5bf 100644 --- a/eox_nelp/signals/tests/test_tasks.py +++ b/eox_nelp/signals/tests/test_tasks.py @@ -19,7 +19,7 @@ _generate_progress_enrollment_data, _get_completion_summary, _post_futurex_progress, - _user_has_passing_grade, + create_external_certificate, dispatch_futurex_progress, ) @@ -28,25 +28,6 @@ TRUTHY_ACTIVATION_VALUES = [1, "true", "activated", ["activated"], True, {"activated": "true"}] -class UserHasPassingGradeTestCase(unittest.TestCase): - """Test class for function `_user_has_passing_grade`""" - - @patch("eox_nelp.signals.tasks.CourseGradeFactory") - def test_call_user_has_passing_grade(self, course_grade_factory_mock): - """Test when `_user_has_passing_grade` is called - with the required parameters. Check the functions inside are called with - their desired values. - - Expected behavior: - - CourseGradeFactory class is used with the right values. - """ - user, _ = User.objects.get_or_create(username="vader") - course_id = "course-v1:test+Cx105+2022_T4" - - _user_has_passing_grade(user, course_id) - course_grade_factory_mock().read.assert_called_with(user, course_key=CourseKey.from_string(course_id)) - - @ddt class DipatchFuturexProgressTestCase(unittest.TestCase): """Test class for function `dispatch_futurex_progress`""" @@ -359,3 +340,26 @@ def test_social_user_not_found(self): f"ERROR:{tasks.__name__}:{log_error}" ]) self.assertDictEqual(expected_data, progress_data) + + +class CreateExternalCertificateTestCase(unittest.TestCase): + """Test class for create_external_certificate function""" + + @patch("eox_nelp.signals.tasks.ExternalCertificatesApiClient") + def test_certificate_creation(self, api_mock): + """Test standard call with the required parameters. + + Expected behavior: + - api_mock was called once. + - create_external_certificate was called with the rigth parameters. + """ + certificate_data = { + "this": "is_a_test", + } + + create_external_certificate(certificate_data) + + api_mock.assert_called_once() + api_mock.return_value.create_external_certificate.assert_called_once_with( + certificate_data + ) diff --git a/eox_nelp/signals/tests/test_utils.py b/eox_nelp/signals/tests/test_utils.py new file mode 100644 index 00000000..cac59524 --- /dev/null +++ b/eox_nelp/signals/tests/test_utils.py @@ -0,0 +1,105 @@ +"""This file contains all the test for signals/utils.py file. +Classes: + UserHasPassingGradeTestCase: Test _user_has_passing_grade function. + GenerateExternalCertificateDataTestCase: Test _generate_external_certificate_data function. +""" +import unittest + +from django.contrib.auth import get_user_model +from django.utils import timezone +from mock import Mock, patch +from opaque_keys.edx.keys import CourseKey +from openedx_events.learning.data import CertificateData, CourseData, UserData, UserPersonalData + +from eox_nelp.signals.utils import _generate_external_certificate_data, _user_has_passing_grade + +User = get_user_model() + + +class UserHasPassingGradeTestCase(unittest.TestCase): + """Test class for function `_user_has_passing_grade`""" + + @patch("eox_nelp.signals.utils.CourseGradeFactory") + def test_call_user_has_passing_grade(self, course_grade_factory_mock): + """Test when `_user_has_passing_grade` is called + with the required parameters. Check the functions inside are called with + their desired values. + + Expected behavior: + - CourseGradeFactory class is used with the right values. + """ + user, _ = User.objects.get_or_create(username="vader") + course_id = "course-v1:test+Cx105+2022_T4" + + _user_has_passing_grade(user, course_id) + course_grade_factory_mock().read.assert_called_with(user, course_key=CourseKey.from_string(course_id)) + + +class GenerateExternalCertificateDataTestCase(unittest.TestCase): + """Test class for function `_generate_external_certificate_data`""" + + def setUp(self): + """ Set common conditions for test cases.""" + self.user, _ = User.objects.get_or_create( + username="10024578", + ) + self.certificate_data = CertificateData( + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email="harry@potter.com", + name="Harry Potter", + ), + id=self.user.id, + is_active=True, + ), + course=CourseData( + course_key=CourseKey.from_string("course-v1:test+Cx105+2022_T4"), + ), + mode="audit", + grade=15, + current_status="non-passing", + download_url="", + name="", + ) + + @patch("eox_nelp.signals.utils._user_has_passing_grade") + @patch("eox_nelp.signals.utils.GeneratedCertificate") + def test_generate_certificate_data(self, generate_certificate_mock, passing_mock): + """This tests the normal behavior of the method `_generate_external_certificate_data` + + Expected behavior: + - Result is as the expected value + - GeneratedCertificate mock is called with the right parameters. + - _user_has_passing_grade is called with the right parameters. + """ + time = timezone.now() + certificate = Mock() + certificate.id = 85 + generate_certificate_mock.objects.get.return_value = certificate + passing_mock.return_value = True + + expected_value = { + "id": certificate.id, + "created_at": time, + "expiration_date": time + timezone.timedelta(days=365), + "grade": self.certificate_data.grade, + "is_passing": True, + "user": { + "national_id": self.user.username, + "english_name": self.certificate_data.user.pii.name, + "arabic_name": "", + } + } + + result = _generate_external_certificate_data(time, self.certificate_data) + + self.assertEqual(result, expected_value) + generate_certificate_mock.objects.get.assert_called_once_with( + user=self.user, + course_id=self.certificate_data.course.course_key, + ) + passing_mock.assert_called_once_with( + self.user, + str(self.certificate_data.course.course_key) + ) diff --git a/eox_nelp/signals/utils.py b/eox_nelp/signals/utils.py new file mode 100644 index 00000000..66e5d19c --- /dev/null +++ b/eox_nelp/signals/utils.py @@ -0,0 +1,60 @@ +"""Common function for the signals module. + +Functions: + _generate_external_certificate_data: Generates dict data from CertificateData. + _user_has_passing_grade: Determines if the user has a passing grade +""" +from django.contrib.auth import get_user_model +from django.utils import timezone +from eox_core.edxapp_wrapper.certificates import get_generated_certificate +from eox_core.edxapp_wrapper.grades import get_course_grade_factory +from opaque_keys.edx.keys import CourseKey + +CourseGradeFactory = get_course_grade_factory() +GeneratedCertificate = get_generated_certificate() +User = get_user_model() + + +def _generate_external_certificate_data(timestamp, certificate_data): + """ + + Args: + timestamp: Date when the certificate was created. + certificate: This an instance of the class defined in this link + https://github.com/eduNEXT/openedx-events/blob/main/openedx_events/learning/data.py#L100 + and will provide of the user certificate data. + """ + user = User.objects.get(id=certificate_data.user.id) + certificate = GeneratedCertificate.objects.get( + user=user, + course_id=certificate_data.course.course_key, + ) + extra_info = getattr(user, "extrainfo", None) + + return { + "id": certificate.id, + "created_at": timestamp, + # Certificate doesn't have an expiration date, so this is a thing that the client must define. + "expiration_date": timestamp + timezone.timedelta(days=365), + "grade": certificate_data.grade, + "is_passing": _user_has_passing_grade(user, str(certificate_data.course.course_key)), + "user": { + "national_id": user.username, + "english_name": certificate_data.user.pii.name, + "arabic_name": extra_info.arabic_name if extra_info else "", + } + } + + +def _user_has_passing_grade(user, course_id): + """Determines if a user has passed a course based on the grading policies. + + Args: + user: Instace of Django User model. + course_id: Unique course identifier. + Returns: + course_grade.passed: True if the user has passed the course, otherwise False + """ + course_grade = CourseGradeFactory().read(user, course_key=CourseKey.from_string(course_id)) + + return course_grade.passed