From bcb9668a9e30b27e63c9ac39c663979c4c37e528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrey=20Ca=C3=B1on?= <36200299+andrey-canon@users.noreply.github.com> Date: Tue, 30 Jul 2024 18:43:55 -0500 Subject: [PATCH] feat: implement new celery task for pearson engine integration (#211) --- eox_nelp/admin/student.py | 84 ++++++++++---- eox_nelp/admin/tests/__init__.py | 133 +++++++++++++++++------ eox_nelp/pearson_vue/constants.py | 6 + eox_nelp/pearson_vue/tasks.py | 45 ++++++++ eox_nelp/pearson_vue/tests/test_tasks.py | 88 ++++++++++++++- eox_nelp/signals/receivers.py | 38 +++++-- eox_nelp/signals/tests/test_receivers.py | 42 +++++++ 7 files changed, 368 insertions(+), 68 deletions(-) diff --git a/eox_nelp/admin/student.py b/eox_nelp/admin/student.py index 13724136..8ed85db7 100644 --- a/eox_nelp/admin/student.py +++ b/eox_nelp/admin/student.py @@ -9,11 +9,12 @@ for selected course enrollments. """ +from django.conf import settings from django.contrib import admin from eox_nelp.admin.register_admin_model import register_admin_model as register from eox_nelp.edxapp_wrapper.student import CourseEnrollment, CourseEnrollmentAdmin -from eox_nelp.pearson_vue.tasks import cdd_task, ead_task, real_time_import_task +from eox_nelp.pearson_vue.tasks import cdd_task, ead_task, real_time_import_task, real_time_import_task_v2 @admin.action(description="Execute Pearson RTI request") @@ -30,10 +31,17 @@ def pearson_real_time_action(modeladmin, request, queryset): # pylint: disable= queryset: The QuerySet of selected CourseEnrollment instances. """ for course_enrollment in queryset: - real_time_import_task.delay( - course_id=str(course_enrollment.course_id), - user_id=course_enrollment.user.id, - ) + if getattr(settings, "USE_PEARSON_ENGINE_SERVICE", False): + real_time_import_task_v2.delay( + user_id=course_enrollment.user.id, + exam_id=str(course_enrollment.course_id), + action_name="rti", + ) + else: + real_time_import_task.delay( + course_id=str(course_enrollment.course_id), + user_id=course_enrollment.user.id, + ) @admin.action(description="Execute Pearson EAD Add request") @@ -50,11 +58,19 @@ def pearson_add_ead_action(modeladmin, request, queryset): # pylint: disable=un queryset: The QuerySet of selected CourseEnrollment instances. """ for course_enrollment in queryset: - ead_task.delay( - course_id=str(course_enrollment.course_id), - user_id=course_enrollment.user.id, - transaction_type="Add", - ) + if getattr(settings, "USE_PEARSON_ENGINE_SERVICE", False): + real_time_import_task_v2.delay( + user_id=course_enrollment.user.id, + exam_id=str(course_enrollment.course_id), + action_name="ead", + transaction_type="Add", + ) + else: + ead_task.delay( + course_id=str(course_enrollment.course_id), + user_id=course_enrollment.user.id, + transaction_type="Add", + ) @admin.action(description="Execute Pearson EAD Update request") @@ -71,11 +87,19 @@ def pearson_update_ead_action(modeladmin, request, queryset): # pylint: disable queryset: The QuerySet of selected CourseEnrollment instances. """ for course_enrollment in queryset: - ead_task.delay( - course_id=str(course_enrollment.course_id), - user_id=course_enrollment.user.id, - transaction_type="Update", - ) + if getattr(settings, "USE_PEARSON_ENGINE_SERVICE", False): + real_time_import_task_v2.delay( + user_id=course_enrollment.user.id, + exam_id=str(course_enrollment.course_id), + action_name="ead", + transaction_type="Update", + ) + else: + ead_task.delay( + course_id=str(course_enrollment.course_id), + user_id=course_enrollment.user.id, + transaction_type="Update", + ) @admin.action(description="Execute Pearson EAD Delete request") @@ -92,11 +116,19 @@ def pearson_delete_ead_action(modeladmin, request, queryset): # pylint: disable queryset: The QuerySet of selected CourseEnrollment instances. """ for course_enrollment in queryset: - ead_task.delay( - course_id=str(course_enrollment.course_id), - user_id=course_enrollment.user.id, - transaction_type="Delete", - ) + if getattr(settings, "USE_PEARSON_ENGINE_SERVICE", False): + real_time_import_task_v2.delay( + user_id=course_enrollment.user.id, + exam_id=str(course_enrollment.course_id), + action_name="ead", + transaction_type="Delete", + ) + else: + ead_task.delay( + course_id=str(course_enrollment.course_id), + user_id=course_enrollment.user.id, + transaction_type="Delete", + ) @admin.action(description="Execute Pearson CDD request for student.") @@ -113,9 +145,15 @@ def pearson_cdd_action(modeladmin, request, queryset): # pylint: disable=unused queryset: The QuerySet of selected CourseEnrollment instances. """ for course_enrollment in queryset: - cdd_task.delay( - user_id=course_enrollment.user.id, - ) + if getattr(settings, "USE_PEARSON_ENGINE_SERVICE", False): + real_time_import_task_v2.delay( + user_id=course_enrollment.user.id, + action_name="cdd", + ) + else: + cdd_task.delay( + user_id=course_enrollment.user.id, + ) class NelpCourseEnrollmentAdmin(CourseEnrollmentAdmin): diff --git a/eox_nelp/admin/tests/__init__.py b/eox_nelp/admin/tests/__init__.py index eb68f46a..96d693df 100644 --- a/eox_nelp/admin/tests/__init__.py +++ b/eox_nelp/admin/tests/__init__.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch from ddt import data, ddt, unpack -from django.test import RequestFactory, TestCase +from django.test import RequestFactory, TestCase, override_settings from eox_nelp.admin import ( NelpCourseEnrollmentAdmin, @@ -26,6 +26,42 @@ class TestPearsonAction(TestCase): Unit tests for the pearson actions functions. """ + def setUp(self): + """Setup common conditions for every test case""" + self.request = RequestFactory().get("/admin") + + def _create_mock_enrollment(self, course_id): + """Create a mock course enrollment.""" + user = MagicMock() + user.id = 1 + enrollment = MagicMock() + enrollment.course_id = course_id + enrollment.user = user + + return enrollment + + def _prepare_call_kwargs(self, enrollments, call_args, extra_call_kwargs): + """Prepare the call arguments for the mocked task.""" + mocks_call_kwargs = [] + for enrollment in enrollments: + call_kwargs = { + "user_id": enrollment.user.id, + "course_id": enrollment.course_id, + "exam_id": enrollment.course_id, + } + # Retain only required arguments and update with extra kwargs + call_kwargs = {key: call_kwargs[key] for key in call_args if key in call_kwargs} + call_kwargs.update(extra_call_kwargs) + mocks_call_kwargs.append(call_kwargs) + + return mocks_call_kwargs + + def _assert_mocked_task_calls(self, mocked_task, mocks_call_kwargs): + """Assert that the mocked task was called with the correct parameters.""" + for mock_call_kwargs in mocks_call_kwargs: + mocked_task.assert_any_call(**mock_call_kwargs) + self.assertEqual(mocked_task.call_count, len(mocks_call_kwargs)) + @data( { "mock_task": "eox_nelp.pearson_vue.tasks.real_time_import_task.delay", @@ -70,43 +106,76 @@ class TestPearsonAction(TestCase): @unpack def test_pearson_course_enrollment_action(self, mock_task, admin_action, call_args, extra_call_kwargs): """ - Test that a pearson_action function calls the a task delay with correct parameters. + Test that a pearson_action function calls a task delay with correct parameters. """ - user = MagicMock() - user.id = 1 - course_enrollment_1 = MagicMock() - course_enrollment_1.course_id = "course-v1:TestX+T101+2024_T1" - course_enrollment_1.user = user - course_enrollment_2 = MagicMock() - course_enrollment_2.course_id = "course-v1:FutureX+T102+2025_T1" - course_enrollment_2.user = user - mocks_call_kwargs = [ - { - "course_id": course_enrollment_1.course_id, - "user_id": user.id, - }, - { - "course_id": course_enrollment_2.course_id, - "user_id": user.id, - } + queryset = [ + self._create_mock_enrollment("course-v1:TestX+T101+2024_T1"), + self._create_mock_enrollment("course-v1:FutureX+T102+2025_T1"), ] - for mock_call_kwargs in mocks_call_kwargs: - for key in set(mock_call_kwargs.keys()).difference(call_args): # set main call args - del mock_call_kwargs[key] - mock_call_kwargs.update(extra_call_kwargs) + mocks_call_kwargs = self._prepare_call_kwargs(queryset, call_args, extra_call_kwargs) - queryset = [course_enrollment_1, course_enrollment_2] - modeladmin = MagicMock() + # Call the admin action + with patch(mock_task) as mocked_task: + admin_action(MagicMock(), self.request, queryset) + self._assert_mocked_task_calls(mocked_task, mocks_call_kwargs) - request = RequestFactory().get("/admin") + @data( + { + "admin_action": pearson_real_time_action, + "call_args": ["user_id", "exam_id"], + "extra_call_kwargs": { + "action_name": "rti", + }, + }, + { + "admin_action": pearson_add_ead_action, + "call_args": ["user_id", "exam_id"], + "extra_call_kwargs": { + "transaction_type": "Add", + "action_name": "ead", + }, + + }, + { + "admin_action": pearson_update_ead_action, + "call_args": ["user_id", "exam_id"], + "extra_call_kwargs": { + "transaction_type": "Update", + "action_name": "ead", + }, + }, + { + "admin_action": pearson_delete_ead_action, + "call_args": ["user_id", "exam_id"], + "extra_call_kwargs": { + "transaction_type": "Delete", + "action_name": "ead", + }, + }, + { + "admin_action": pearson_cdd_action, + "call_args": ["user_id"], + "extra_call_kwargs": { + "action_name": "cdd", + }, + }, + ) + @unpack + @override_settings(USE_PEARSON_ENGINE_SERVICE=True) + def test_pearson_course_enrollment_action_v2(self, admin_action, call_args, extra_call_kwargs): + """ + Test that a pearson_action function calls a task delay with correct parameters. + """ + queryset = [ + self._create_mock_enrollment("course-v1:TestX+T101+2024_T1"), + self._create_mock_enrollment("course-v1:FutureX+T102+2025_T1"), + ] + mocks_call_kwargs = self._prepare_call_kwargs(queryset, call_args, extra_call_kwargs) # Call the admin action - with patch(mock_task) as mocked_task: - admin_action(modeladmin, request, queryset) - for mock_call_kwargs in mocks_call_kwargs: - mocked_task.assert_any_call(**mock_call_kwargs) - mocked_task.assert_any_call(**mock_call_kwargs) - self.assertEqual(mocked_task.call_count, len(mocks_call_kwargs)) + with patch("eox_nelp.pearson_vue.tasks.real_time_import_task_v2.delay") as mocked_task: + admin_action(MagicMock(), self.request, queryset) + self._assert_mocked_task_calls(mocked_task, mocks_call_kwargs) @ddt diff --git a/eox_nelp/pearson_vue/constants.py b/eox_nelp/pearson_vue/constants.py index 5aded209..9da153e3 100644 --- a/eox_nelp/pearson_vue/constants.py +++ b/eox_nelp/pearson_vue/constants.py @@ -104,3 +104,9 @@ """ + +ALLOWED_RTI_ACTIONS = { + "rti": "real_time_import", + "cdd": "import_candidate_demographics", + "ead": "import_exam_authorization", +} diff --git a/eox_nelp/pearson_vue/tasks.py b/eox_nelp/pearson_vue/tasks.py index d9802af7..5bea8931 100644 --- a/eox_nelp/pearson_vue/tasks.py +++ b/eox_nelp/pearson_vue/tasks.py @@ -6,7 +6,11 @@ """ from celery import shared_task +from django.contrib.auth import get_user_model +from eox_nelp.api_clients.pearson_engine import PearsonEngineApiClient +from eox_nelp.pearson_vue.constants import ALLOWED_RTI_ACTIONS +from eox_nelp.pearson_vue.pipeline import audit_method, rename_function from eox_nelp.pearson_vue.rti_backend import ( CandidateDemographicsDataImport, ErrorRealTimeImportHandler, @@ -14,6 +18,8 @@ RealTimeImport, ) +User = get_user_model() + @shared_task(bind=True) def real_time_import_task(self, pipeline_index=0, **kwargs): @@ -93,3 +99,42 @@ def rti_error_handler_task(self, pipeline_index=0, **kwargs): error_rti.run_pipeline() except Exception as exc: # pylint: disable=broad-exception-caught self.retry(exc=exc, kwargs=error_rti.backend_data) + + +@shared_task +def real_time_import_task_v2(user_id, exam_id=None, action_name="rti", **kwargs): + """ + Asynchronous task to perform a real-time import action using the Pearson Engine API. + + This task handles different types of import actions, such as real-time import, + importing candidate demographics, and importing exam authorizations, by calling + the appropriate method on the PearsonEngineApiClient. + + Args: + user_id (int): The ID of the user to be processed. + exam_id (str, optional): The ID of the exam for authorization. Default is None. + action_name (str, optional): The action to perform. Default is "rti". + Supported actions are: + - "rti" for real_time_import + - "cdd" for import_candidate_demographics + - "ead" for import_exam_authorization + **kwargs: Additional keyword arguments to pass to the API client method. + + Raises: + KeyError: If action_name is not found in ALLOWED_RTI_ACTIONS. + User.DoesNotExist: If the user with the given user_id does not exist. + """ + action_key = ALLOWED_RTI_ACTIONS[action_name] + + @audit_method(action="Pearson Engine Action") + @rename_function(name=action_key) + def audit_pearson_engine_action(user_id, exam_id, action_key, **kwargs): + action = getattr(PearsonEngineApiClient(), action_key) + + action( + user=User.objects.get(id=user_id), + exam_id=exam_id, + **kwargs + ) + + audit_pearson_engine_action(user_id, exam_id, action_key, **kwargs) diff --git a/eox_nelp/pearson_vue/tests/test_tasks.py b/eox_nelp/pearson_vue/tests/test_tasks.py index 189bdf59..11657d01 100644 --- a/eox_nelp/pearson_vue/tests/test_tasks.py +++ b/eox_nelp/pearson_vue/tests/test_tasks.py @@ -2,9 +2,20 @@ This module contains unit tests for the tasks.py module and its functions. """ import unittest -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock, call, patch -from eox_nelp.pearson_vue.tasks import cdd_task, ead_task, real_time_import_task, rti_error_handler_task +from django.contrib.auth import get_user_model +from django.test import TestCase + +from eox_nelp.pearson_vue.tasks import ( + cdd_task, + ead_task, + real_time_import_task, + real_time_import_task_v2, + rti_error_handler_task, +) + +User = get_user_model() class TestRealTimeImportTask(unittest.TestCase): @@ -94,3 +105,76 @@ class TestErrorValidationTask(TestRealTimeImportTask): """ import_class_patch = "eox_nelp.pearson_vue.tasks.ErrorRealTimeImportHandler" import_task_function = rti_error_handler_task + + +class TestRealTimeImportTaskV2(TestCase): + """ + Unit tests for the real_time_import_task_v2 function. + """ + + def setUp(self): + """Set up a test user for the real-time import task.""" + self.user, _ = User.objects.get_or_create(username="vader") + self.exam_id = "exam123" + self.kwargs = {"extra_info": "test"} + + @patch("eox_nelp.pearson_vue.tasks.PearsonEngineApiClient") + def test_real_time_import_rti(self, mock_api_client): + """Test real-time import action using the Pearson Engine API. + + Expected behavior: + - The real_time_import method is called with the correct parameters. + """ + mock_action = MagicMock() + mock_api_client.return_value = MagicMock(**{"real_time_import": mock_action}) + + real_time_import_task_v2(self.user.id, action_name="rti", **self.kwargs) + + mock_action.assert_called_once_with(user=self.user, exam_id=None, **self.kwargs) + + @patch("eox_nelp.pearson_vue.tasks.PearsonEngineApiClient") + def test_real_time_import_cdd(self, mock_api_client): + """Test candidate demographics import action using the Pearson Engine API. + + Expected behavior: + - The import_candidate_demographics method is called with the correct parameters. + """ + mock_action = MagicMock() + mock_api_client.return_value = MagicMock(**{"import_candidate_demographics": mock_action}) + + real_time_import_task_v2(self.user.id, action_name="cdd", **self.kwargs) + + mock_action.assert_called_once_with(user=self.user, exam_id=None, **self.kwargs) + + @patch("eox_nelp.pearson_vue.tasks.PearsonEngineApiClient") + def test_real_time_import_ead(self, mock_api_client): + """Test exam authorization import action using the Pearson Engine API. + + Expected behavior: + - The import_exam_authorization method is called with the correct parameters. + """ + mock_action = MagicMock() + mock_api_client.return_value = MagicMock(**{"import_exam_authorization": mock_action}) + + real_time_import_task_v2(self.user.id, exam_id=self.exam_id, action_name="ead", **self.kwargs) + + mock_action.assert_called_once_with(user=self.user, exam_id=self.exam_id, **self.kwargs) + + def test_real_time_import_invalid_action(self): + """Test that a KeyError is raised for an invalid action name. + + Expected behavior: + - KeyError is raised when an invalid action name is provided. + """ + with self.assertRaises(KeyError): + real_time_import_task_v2(self.user.id, action_name="invalid_action") + + @patch('eox_nelp.pearson_vue.tasks.PearsonEngineApiClient') + def test_real_time_import_user_not_found(self, mock_api_client): # pylint: disable=unused-argument + """Test that a DoesNotExist is raised for an invalid user id. + + Expected behavior: + - DoesNotExist is raised when an invalid usser id is provided. + """ + with self.assertRaises(User.DoesNotExist): + real_time_import_task_v2(12345678, action_name="rti") diff --git a/eox_nelp/signals/receivers.py b/eox_nelp/signals/receivers.py index dc7b542d..46b442f7 100644 --- a/eox_nelp/signals/receivers.py +++ b/eox_nelp/signals/receivers.py @@ -26,7 +26,7 @@ from eox_nelp.notifications.tasks import create_course_notifications as create_course_notifications_task from eox_nelp.payment_notifications.models import PaymentNotification -from eox_nelp.pearson_vue.tasks import real_time_import_task +from eox_nelp.pearson_vue.tasks import real_time_import_task, real_time_import_task_v2 from eox_nelp.signals.tasks import ( course_completion_mt_updater, create_external_certificate, @@ -393,10 +393,17 @@ def pearson_vue_course_completion_handler(instance, **kwargs): # pylint: disabl if not getattr(settings, "PEARSON_RTI_ACTIVATE_COMPLETION_GATE", False): return - real_time_import_task.delay( - user_id=instance.user_id, - course_id=str(instance.context_key), - ) + if getattr(settings, "USE_PEARSON_ENGINE_SERVICE", False): + real_time_import_task_v2.delay( + user_id=instance.user_id, + exam_id=str(instance.context_key), + action_name="rti", + ) + else: + real_time_import_task.delay( + user_id=instance.user_id, + course_id=str(instance.context_key), + ) def pearson_vue_course_passed_handler(user, course_id, **kwargs): # pylint: disable=unused-argument @@ -412,9 +419,18 @@ def pearson_vue_course_passed_handler(user, course_id, **kwargs): # pylint: dis if not getattr(settings, "PEARSON_RTI_ACTIVATE_GRADED_GATE", False): return - real_time_import_task.delay( - course_id=str(course_id), - user_id=user.id, - is_passing=True, - is_graded=True, - ) + if getattr(settings, "USE_PEARSON_ENGINE_SERVICE", False): + real_time_import_task_v2.delay( + user_id=user.id, + exam_id=str(course_id), + action_name="rti", + is_passing=True, + is_graded=True, + ) + else: + real_time_import_task.delay( + course_id=str(course_id), + user_id=user.id, + is_passing=True, + is_graded=True, + ) diff --git a/eox_nelp/signals/tests/test_receivers.py b/eox_nelp/signals/tests/test_receivers.py index d0c8defc..9d31da5b 100644 --- a/eox_nelp/signals/tests/test_receivers.py +++ b/eox_nelp/signals/tests/test_receivers.py @@ -732,6 +732,27 @@ def test_call_async_task(self, task_mock): course_id=course_id, ) + @override_settings(PEARSON_RTI_ACTIVATE_COMPLETION_GATE=True, USE_PEARSON_ENGINE_SERVICE=True) + @patch("eox_nelp.signals.receivers.real_time_import_task_v2") + def test_call_async_task_v2(self, task_mock): + """Test that the async task is called with the right parameters + + Expected behavior: + - delay method is called with the right values. + """ + instance = Mock() + instance.user_id = 5 + course_id = "course-v1:test+Cx105+2022_T4" + instance.context_key = CourseKey.from_string(course_id) + + pearson_vue_course_completion_handler(instance) + + task_mock.delay.assert_called_with( + user_id=instance.user_id, + exam_id=course_id, + action_name="rti" + ) + class PearsonVueCoursePassedHandlerTestCase(unittest.TestCase): """Test class for mt_course_passed_handler function.""" @@ -770,6 +791,27 @@ def test_call_async_task(self, task_mock): is_graded=True ) + @override_settings(PEARSON_RTI_ACTIVATE_GRADED_GATE=True, USE_PEARSON_ENGINE_SERVICE=True) + @patch("eox_nelp.signals.receivers.real_time_import_task_v2") + def test_call_async_task_v2(self, task_mock): + """Test that the async task is called with the right parameters + + Expected behavior: + - delay method is called with the right values. + """ + course_id = "course-v1:test+Cx105+2022_T4" + user_instance, _ = User.objects.get_or_create(username="Severus") + + pearson_vue_course_passed_handler(user_instance, CourseKey.from_string(course_id)) + + task_mock.delay.assert_called_with( + exam_id=course_id, + user_id=user_instance.id, + action_name="rti", + is_passing=True, + is_graded=True, + ) + class CreateUserSignUpSourceByEnrollmentTestCase(unittest.TestCase): """Test class for create_usersignupsource_by_enrollment."""