diff --git a/eox_nelp/pearson_vue/pipeline.py b/eox_nelp/pearson_vue/pipeline.py index 2ee642d5..9570f468 100644 --- a/eox_nelp/pearson_vue/pipeline.py +++ b/eox_nelp/pearson_vue/pipeline.py @@ -37,6 +37,7 @@ ) from eox_nelp.pearson_vue.utils import generate_client_authorization_id, update_xml_with_dict from eox_nelp.signals.utils import get_completed_and_graded +from eox_nelp.utils import camel_to_snake try: from eox_audit_model.decorators import audit_method, rename_function @@ -227,7 +228,7 @@ def import_candidate_demographics(cdd_request, **kwargs): # pylint: disable=unu response = api_client.import_candidate_demographics(payload) if response.get("status", "error") == "accepted": - return response + return {"cdd_import": response} raise PearsonImportError( exception_reason=f"Import candidate demographics pipeline has failed with the following response: {response}", @@ -276,7 +277,7 @@ def import_exam_authorization(ead_request, **kwargs): # pylint: disable=unused- response = api_client.import_exam_authorization(payload) if response.get("status", "error") == "accepted": - return response + return {"ead_import": response} raise PearsonImportError( exception_reason=f"Import exam authorization pipeline has failed with the following response: {response}", @@ -613,3 +614,42 @@ def get_enrollment_from_id(enrollment_id, enrollment=None, **kwargs): # pylint: pass return {} + + +def save_audit_pipeline( + pipeline_backend, + last_step, + cdd_import=None, + ead_import=None, + **kwargs +): # pylint: disable=unused-argument + """This is not a pipe step, could be but its no the intention because inside a pipeline could + be more than one import. so this is a helper for pipelines. + + Args: + pipeline_backend (str): pipeline class name + last_step (str): last function executed + cdd_import (dict, optional): Defaults to None. + ead_import (dict, optional): Defaults to None. + """ + + @audit_method(action=f"Pearson Vue Pipeline Execution: {pipeline_backend}") + @rename_function(name=f"audit_pipeline_{camel_to_snake(pipeline_backend)}") + def executed_pipeline(pipeline_backend, last_step, cdd_import, ead_import): + logger.info( + "Pipeline %s executed until last step %s", + pipeline_backend, + last_step, + ) + logger.info( + "Succesfull imports. cdd: %s. ead: %s", + cdd_import, + ead_import, + ) + + executed_pipeline( + pipeline_backend=pipeline_backend, + last_step=last_step, + cdd_import=cdd_import, + ead_import=ead_import, + ) diff --git a/eox_nelp/pearson_vue/rti_backend.py b/eox_nelp/pearson_vue/rti_backend.py index 290735c8..1187f88c 100644 --- a/eox_nelp/pearson_vue/rti_backend.py +++ b/eox_nelp/pearson_vue/rti_backend.py @@ -31,11 +31,36 @@ handle_course_completion_status, import_candidate_demographics, import_exam_authorization, + save_audit_pipeline, validate_cdd_request, validate_ead_request, ) +def audit_pipeline(func): + """Decorator that wraps a class method with a try-finally block. + + Args: + func: The method to be decorated. + + Returns: + A wrapper function that executes the decorated method with a try-finally block. + """ + def wrapper(self, *args, **kwargs): + try: + # Call the original function with self, args, and kwargs + return func(self, *args, **kwargs) + finally: + if self.use_audit_pipeline and not self.backend_data.get("catched_pearson_error"): + save_audit_pipeline( + pipeline_backend=self.__class__.__name__, + last_step=self.get_pipeline()[self.backend_data.get("pipeline_index", 0)].__name__, + **self.backend_data + ) + + return wrapper + + class AbstractBackend(ABC): """ Base class for managing backend operations and executing pipelines. @@ -53,7 +78,7 @@ class AbstractBackend(ABC): handle_error(exception: Exception, failed_step_pipeline: str): Handles errors during pipeline execution. """ - def __init__(self, **kwargs): + def __init__(self, use_audit_pipeline=True, **kwargs): """ Initializes the AbstractBackend instance with the provided keyword arguments. @@ -61,7 +86,9 @@ def __init__(self, **kwargs): **kwargs: Additional keyword arguments to configure the AbstractBackend instance. """ self.backend_data = kwargs.copy() + self.use_audit_pipeline = use_audit_pipeline + @audit_pipeline def run_pipeline(self): """ Executes the pipeline by iterating through the pipeline functions. @@ -78,6 +105,7 @@ def run_pipeline(self): try: result = func(**self.backend_data) or {} except PearsonBaseError as pearson_error: + self.backend_data["catched_pearson_error"] = True self.handle_error(pearson_error, func.__name__) break diff --git a/eox_nelp/pearson_vue/tests/test_pipeline.py b/eox_nelp/pearson_vue/tests/test_pipeline.py index 2316b15d..5bc3a777 100644 --- a/eox_nelp/pearson_vue/tests/test_pipeline.py +++ b/eox_nelp/pearson_vue/tests/test_pipeline.py @@ -39,6 +39,7 @@ handle_course_completion_status, import_candidate_demographics, import_exam_authorization, + save_audit_pipeline, validate_cdd_request, validate_ead_request, ) @@ -1231,3 +1232,59 @@ def test_get_enrollment_with_provided_enrollment(self): # Verify mock calls CourseEnrollment.objects.get.assert_not_called() + + +class TestSaveAuditPipeline(unittest.TestCase): + """ + Unit tests for the save_audit_pipeline method. + """ + + def test_audit_succesfull_cdd(self): + """Test correct behaviour calling save_audit_pipeline. + + Expected behavior: + - The result is the expected value(None). + - Expected log info. + """ + pipeline_backend = "CandidateDemographicsDataImport" + last_step = "import_candidate_demographics" + cdd_import = {} + log_info = [ + f"INFO:{pipeline.__name__}:" + f"Pipeline {pipeline_backend} executed until last step {last_step}", + f"INFO:{pipeline.__name__}:" + f"Succesfull imports. cdd: {cdd_import}. ead: {None}", + ] + + with self.assertLogs(pipeline.__name__, level="INFO") as logs: + self.assertIsNone(save_audit_pipeline( + pipeline_backend=pipeline_backend, + last_step=last_step, + cdd_import=cdd_import, + )) + self.assertListEqual(log_info, logs.output) + + def test_audit_succesfull_ead(self): + """Test correct behaviour calling audit_sucessfull_import. + + Expected behavior: + - The result is the expected value(None). + - Expected log info. + """ + pipeline_backend = "ExamAuthorizationDataImport" + last_step = "import_exam_authorization" + ead_import = {} + log_info = [ + f"INFO:{pipeline.__name__}:" + f"Pipeline {pipeline_backend} executed until last step {last_step}", + f"INFO:{pipeline.__name__}:" + f"Succesfull imports. cdd: {None}. ead: {ead_import}", + ] + + with self.assertLogs(pipeline.__name__, level="INFO") as logs: + self.assertIsNone(save_audit_pipeline( + pipeline_backend=pipeline_backend, + last_step=last_step, + ead_import=ead_import, + )) + self.assertListEqual(log_info, logs.output) diff --git a/eox_nelp/pearson_vue/tests/test_rti_backend.py b/eox_nelp/pearson_vue/tests/test_rti_backend.py index 3ea27f7b..4bea1fc8 100644 --- a/eox_nelp/pearson_vue/tests/test_rti_backend.py +++ b/eox_nelp/pearson_vue/tests/test_rti_backend.py @@ -27,7 +27,7 @@ def setUp(self): # pylint: disable=invalid-name Set up the test environment. """ self.backend_data = {"pipeline_index": 0} - self.rti = self.rti_backend_class(**self.backend_data) + self.rti = self.rti_backend_class(**self.backend_data, use_audit_pipeline=True) def test_init(self): """ @@ -38,7 +38,8 @@ def test_init(self): """ self.assertDictEqual(self.rti.backend_data, self.backend_data) - def test_run_pipeline(self): + @patch("eox_nelp.pearson_vue.rti_backend.save_audit_pipeline") + def test_run_pipeline(self, audit_pipeline_mock): """ Test the execution of the RTI pipeline. @@ -46,10 +47,11 @@ def test_run_pipeline(self): - Pipeline method 1 is called with the original data. - Pipeline method 2 is called with updated data. - backend_data attribute is the expected value. + - audit_pipeline mock called with desired parameters. """ # Mock pipeline functions - func1 = MagicMock(return_value={"updated_key": "value1"}) - func2 = MagicMock(return_value={"additional_key": "value2"}) + func1 = MagicMock(return_value={"updated_key": "value1"}, __name__="func1") + func2 = MagicMock(return_value={"additional_key": "value2"}, __name__="func2") self.rti.get_pipeline = MagicMock(return_value=[func1, func2]) self.rti.run_pipeline() @@ -64,6 +66,11 @@ def test_run_pipeline(self): **func2(), # Include data from func2 }, ) + audit_pipeline_mock.assert_called_once_with( + pipeline_backend=self.rti.__class__.__name__, + last_step=func2.__name__, + **self.rti.backend_data + ) def test_pipeline_index(self): """ @@ -81,7 +88,7 @@ def test_pipeline_index(self): func3 = MagicMock() func3_output = {"last_value": "value3"} func3.side_effect = [Exception("Test exception"), func3_output] - rti = self.rti_backend_class(pipeline_index=0) + rti = self.rti_backend_class(pipeline_index=0, use_audit_pipeline=False) rti.get_pipeline = MagicMock(return_value=[func1, func2, func3]) with self.assertRaises(Exception): @@ -107,7 +114,8 @@ def test_pipeline_index(self): }, ) - def test_safely_pipeline_termination(self): + @patch("eox_nelp.pearson_vue.rti_backend.save_audit_pipeline") + def test_safely_pipeline_termination(self, audit_pipeline_mock): """ Test the execution of the RTI finished after the second function call due `safely_pipeline_termination` kwarg. @@ -119,12 +127,13 @@ def test_safely_pipeline_termination(self): - Pipeline method 4 is not called. - backend_data attribute is the expected value. Without func3,func4 data and pipeline index in the last. + - audit_pipeline mock called with desired parameters. """ # Mock pipeline functions - func1 = MagicMock(return_value={"updated_key": "value1"}) - func2 = MagicMock(return_value={"safely_pipeline_termination": True}) - func3 = MagicMock(return_value={"additional_key": "value3"}) - func4 = MagicMock(return_value={"additional_key": "value4"}) + func1 = MagicMock(return_value={"updated_key": "value1"}, __name__="func1") + func2 = MagicMock(return_value={"safely_pipeline_termination": True}, __name__="func2") + func3 = MagicMock(return_value={"additional_key": "value3"}, __name__="func3") + func4 = MagicMock(return_value={"additional_key": "value4"}, __name__="func4") self.rti.get_pipeline = MagicMock(return_value=[func1, func2, func2]) @@ -143,6 +152,11 @@ def test_safely_pipeline_termination(self): **func2(), # Include data from func2 (with safely_pipeline_termination) }, ) + audit_pipeline_mock.assert_called_once_with( + pipeline_backend=self.rti.__class__.__name__, + last_step=func2.__name__, + **self.rti.backend_data + ) def test_get_pipeline(self): """ @@ -165,13 +179,14 @@ class TestRealTimeImport(TestAbstractBackendMixin, unittest.TestCase): """ rti_backend_class = RealTimeImport + @patch("eox_nelp.pearson_vue.rti_backend.save_audit_pipeline") + @patch("eox_nelp.pearson_vue.tasks.rti_error_handler_task") @data( PearsonValidationError(inspect.currentframe(), "error: ['String to short.']"), PearsonKeyError(inspect.currentframe(), "eligibility_appt_date_first"), PearsonAttributeError(inspect.currentframe(), "Settings' object has no attribute PERITA") ) - @patch("eox_nelp.pearson_vue.tasks.rti_error_handler_task") - def test_launch_validation_error_pipeline(self, pearson_error, rti_error_handler_task_mock): + def test_launch_validation_error_pipeline(self, pearson_error, rti_error_handler_task_mock, audit_pipeline_mock): """ Test the execution of the RTI finished after the second function call due `launch_validation_error_pipeline` kwarg. @@ -184,6 +199,7 @@ def test_launch_validation_error_pipeline(self, pearson_error, rti_error_handler - backend_data attribute is the expected value. Without func3,func4 data and pipeline index in the last. - rti_error_handler_task is called with executed__pipeline_kwargs and error_validation_kwargs. + - audit pipeline is not called due catched_pearson_error. """ # Mock pipeline functions func1 = MagicMock(return_value={"updated_key": "value1"}) @@ -208,6 +224,7 @@ def test_launch_validation_error_pipeline(self, pearson_error, rti_error_handler self.rti.backend_data, { "pipeline_index": 1, # includes the pipe executed until break due exception + 'catched_pearson_error': True, # pipeline error flag **func1(), # Include data from func1 () }, ) @@ -217,6 +234,7 @@ def test_launch_validation_error_pipeline(self, pearson_error, rti_error_handler user_id=None, course_id=None, ) + audit_pipeline_mock.assert_not_called() class TestExamAuthorizationDataImport(TestRealTimeImport):