Skip to content

Commit

Permalink
And/generate certificate based on rten request (#187)
Browse files Browse the repository at this point in the history
* feat: add new backend method

* feat: add new backend  and pipeline steps that handle the result notification response

* feat: add test for new modifications, pipes and backend

* feat: implement pearson custom exceptions
  • Loading branch information
andrey-canon authored Jul 8, 2024
1 parent 40d40db commit 65c41a0
Show file tree
Hide file tree
Showing 10 changed files with 497 additions and 8 deletions.
10 changes: 10 additions & 0 deletions eox_nelp/edxapp_wrapper/backends/certificates_m_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,13 @@ def get_generated_certificates_admin():
return certificates.admin.GeneratedCertificateAdmin

return admin.ModelAdmin


def get_generate_course_certificate_method():
"""Allow to get the generate_course_certificate method.
https://github.com/nelc/edx-platform/blob/open-release/palm.nelp/lms/djangoapps/certificates/generation.py#L20
Returns:
generate_course_certificate method.
"""
return certificates.generation.generate_course_certificate
1 change: 1 addition & 0 deletions eox_nelp/edxapp_wrapper/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
backend = import_module(settings.EOX_NELP_CERTIFICATES_BACKEND)

GeneratedCertificateAdmin = backend.get_generated_certificates_admin()
generate_course_certificate = backend.get_generate_course_certificate_method()
9 changes: 9 additions & 0 deletions eox_nelp/edxapp_wrapper/test_backends/certificates_m_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,12 @@ def get_generated_certificate():
return create_test_model(
"GeneratedCertificate", "eox_nelp", __package__, generated_certificate_fields
)


def get_generate_course_certificate_method():
"""Return generate_course_certificate mock method.
Returns:
Mock instance.
"""
return Mock()
20 changes: 19 additions & 1 deletion eox_nelp/pearson_vue/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
UNREVOKE_RESULT,
)
from eox_nelp.pearson_vue.models import PearsonRTENEvent
from eox_nelp.pearson_vue.rti_backend import ResultNotificationBackend


class PearsonRTENBaseView(generics.ListCreateAPIView):
Expand Down Expand Up @@ -130,9 +131,26 @@ class ResultNotificationView(PearsonRTENBaseView):
This view handles the creation of Result Notification events in the Pearson RTEN system.
The `event_type` attribute is set to "resultNotification".
"""

event_type = RESULT_NOTIFICATION

def create(self, request, *args, **kwargs):
"""
Execute the parent create method and allow to run the result notification pipeline.
Args:
request (Request): The request object containing the data.
Returns:
Response: Response object with status code 201 and an empty dictionary.
"""
response = super().create(request, *args, **kwargs)

if response.status_code == status.HTTP_200_OK and getattr(settings, "ENABLE_CERTIFICATE_PUBLISHER", False):
result_notification = ResultNotificationBackend(request_data=request.data)
result_notification.run_pipeline()

return response


class PlaceHoldView(PearsonRTENBaseView):
"""
Expand Down
5 changes: 5 additions & 0 deletions eox_nelp/pearson_vue/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,8 @@ class PearsonImportError(PearsonBaseError):
Error related with a failure response from Pearson Vue site.
"""
exception_type = "import-error"


class PearsonTypeError(PearsonBaseError):
"""Pearson type error class"""
exception_type = "type-error"
119 changes: 118 additions & 1 deletion eox_nelp/pearson_vue/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@
import phonenumbers
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from pydantic import ValidationError

from eox_nelp.api_clients.pearson_rti import PearsonRTIApiClient
from eox_nelp.edxapp_wrapper.student import anonymous_id_for_user
from eox_nelp.edxapp_wrapper.certificates import generate_course_certificate
from eox_nelp.edxapp_wrapper.student import AnonymousUserId, CourseEnrollment, anonymous_id_for_user
from eox_nelp.pearson_vue.constants import PAYLOAD_CDD, PAYLOAD_EAD, PAYLOAD_PING_DATABASE
from eox_nelp.pearson_vue.data_classes import CddRequest, EadRequest
from eox_nelp.pearson_vue.exceptions import (
PearsonAttributeError,
PearsonBaseError,
PearsonImportError,
PearsonKeyError,
PearsonTypeError,
PearsonValidationError,
)
from eox_nelp.pearson_vue.utils import generate_client_authorization_id, update_xml_with_dict
Expand Down Expand Up @@ -496,3 +499,117 @@ def raise_audit_pearson_exception(exception_dict, failed_step_pipeline):
raise_audit_pearson_exception(failed_step_pipeline=failed_step_pipeline, exception_dict=exception_dict)
except PearsonBaseError as exc:
logger.error(exc)


def extract_result_notification_data(request_data, **kwargs): # pylint: disable=unused-argument
"""
Extracts result notification data from the request.
Args:
request_data (dict): The dictionary containing the Result Notification request data.
**kwargs: Additional keyword arguments.
Returns:
dict: A dictionary containing extracted data including exam result, client authorization ID,
enrollment ID, anonymous user model ID, and anonymous user ID.
"""

try:
client_authorization_id = request_data["authorization"]["clientAuthorizationID"]
client_authorization_elements = client_authorization_id.split("-")
enrollment_id = client_authorization_elements[0]
anonymous_user_model_id = client_authorization_elements[1]
anonymous_user_id = request_data["clientCandidateID"].replace("NELC", "")

return {
"exam_result": request_data["exams"]["exam"][0]["examResult"],
"client_authorization_id": client_authorization_id,
"enrollment_id": enrollment_id,
"anonymous_user_model_id": anonymous_user_model_id,
"anonymous_user_id": anonymous_user_id,
}
except (KeyError, IndexError) as exc:
raise PearsonKeyError(exception_reason=str(exc), pipe_frame=inspect.currentframe()) from exc


def generate_external_certificate(enrollment=None, exam_result=None, **kwargs): # pylint: disable=unused-argument
"""
Generates an external certificate if the exam result meets the passing criteria.
Args:
enrollment (object): The enrollment object associated with the user and course.
exam_result (dict): The dictionary containing the exam result data.
**kwargs: Additional keyword arguments.
"""
if not enrollment or not exam_result:
raise PearsonTypeError(
exception_reason="Method generate_external_certificate has failed due to a missing variable",
pipe_frame=inspect.currentframe()
)

passing_score = float(exam_result.get("passingScore", 1))
score = float(exam_result.get("score", 0))

if score >= passing_score:
generate_course_certificate(
enrollment.user,
enrollment.course_id,
"downloadable",
enrollment.mode,
score,
"batch"
)


def get_enrollment_from_anonymous_user_id(
anonymous_user_model_id,
enrollment=None,
**kwargs
): # pylint: disable=unused-argument
"""
Retrieves enrollment information based on the anonymous user model ID.
Args:
anonymous_user_model_id (str): The ID of the anonymous user model.
enrollment (object, optional): An optional enrollment object.
**kwargs: Additional keyword arguments.
Returns:
dict: A dictionary containing the enrollment object if found, otherwise an empty dictionary.
"""
if not enrollment:
try:
anonymous_user_id = AnonymousUserId.objects.get(id=anonymous_user_model_id)

return {
"enrollment": CourseEnrollment.objects.get(
user=anonymous_user_id.user,
course=anonymous_user_id.course_id,
)
}
except ObjectDoesNotExist:
pass

return {}


def get_enrollment_from_id(enrollment_id, enrollment=None, **kwargs): # pylint: disable=unused-argument
"""
Retrieves enrollment information based on the enrollment ID.
Args:
enrollment_id (str): The ID of the enrollment.
**kwargs: Additional keyword arguments.
Returns:
dict: A dictionary containing the enrollment object if found, otherwise an empty dictionary.
"""
if not enrollment:
try:
return {
"enrollment": CourseEnrollment.objects.get(id=enrollment_id)
}
except ObjectDoesNotExist:
pass

return {}
48 changes: 45 additions & 3 deletions eox_nelp/pearson_vue/rti_backend.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
"""
This module provides the RealTimeImport class, which is responsible for orchestrating the RTI pipeline
and executing various processes related to rti.
This module provides classes for managing various backend operations and executing their corresponding pipelines.
Classes:
RealTimeImport: Class for managing RTI operations and executing the pipeline.
AbstractBackend: Base class for managing backend operations and executing pipelines.
ErrorRealTimeImportHandler: Class for managing validation error pipelines and executing
the pipeline for data validation.
RealTimeImport: Class for managing RTI (Real Time Import) operations and executing the pipeline.
ExamAuthorizationDataImport: Class for managing EAD requests (Exam Authorization Data operations)
and executing the pipeline.
CandidateDemographicsDataImport: Class for managing CDD requests (Candidate Demographics Data operations)
and executing the pipeline.
ResultNotificationBackend: Class for managing Result Notification operations and executing the
corresponding pipeline.
"""
import importlib
from abc import ABC, abstractmethod
Expand All @@ -14,6 +22,10 @@
build_cdd_request,
build_ead_request,
check_service_availability,
extract_result_notification_data,
generate_external_certificate,
get_enrollment_from_anonymous_user_id,
get_enrollment_from_id,
get_exam_data,
get_user_data,
handle_course_completion_status,
Expand Down Expand Up @@ -194,3 +206,33 @@ def get_pipeline(self):
check_service_availability,
import_candidate_demographics,
]


class ResultNotificationBackend(RealTimeImport):
"""
Class for managing the Result Notification operations and executing the corresponding pipeline.
This class inherits from RealTimeImport and is responsible for orchestrating the pipeline
for handling result notifications, including extracting result notification data,
retrieving enrollment information, and generating external certificates.
"""

def get_pipeline(self):
"""
Returns the Result Notification pipeline, which is a list of functions to be executed.
The pipeline includes the following steps:
1. `extract_result_notification_data`: Extracts the result notification data.
2. `get_enrollment_from_id`: Retrieves the enrollment information based on the given ID.
3. `get_enrollment_from_anonymous_user_id`: Retrieves the enrollment information for an anonymous user.
4. `generate_external_certificate`: Generates an external certificate based on the enrollment data.
Returns:
list: A list of functions representing the pipeline steps.
"""
return [
extract_result_notification_data,
get_enrollment_from_id,
get_enrollment_from_anonymous_user_id,
generate_external_certificate,
]
40 changes: 39 additions & 1 deletion eox_nelp/pearson_vue/tests/api/v1/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
TestUnrevokeResultView: Unit tests for the UnrevokeResultView.
"""
import unittest
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.test import override_settings
Expand Down Expand Up @@ -47,12 +48,13 @@ def setUp(self): # pylint: disable=invalid-name
self.user, _ = User.objects.get_or_create(username='testuser', password='12345')
self.client.force_authenticate(user=self.user)

@override_settings(ENABLE_CERTIFICATE_PUBLISHER=False)
def test_create_result_notification_event(self):
"""
Test creating an event.
Expected behavior:
- The number of recors has incrsed in 1.
- The number of records has increased in 1.
- Response returns a 200 status code.
- Response data is empty.
Expand Down Expand Up @@ -131,6 +133,42 @@ class TestResultNotificationView(RTENMixin, unittest.TestCase):
"""
event_type = RESULT_NOTIFICATION

@patch("eox_nelp.pearson_vue.api.v1.views.ResultNotificationBackend")
def test_pipeline_execution(self, result_notification_mock):
"""
Test that a new event is created and the result notification pipeline is run
when ENABLE_CERTIFICATE_PUBLISHER is True.
Expected behavior:
- The number of records has increased in 1.
- Response returns a 200 status code.
- Response data is empty.
- ResultNotificationBackend was initialized with the right data
- run_pipeline method was called once.
"""
# pylint: disable=no-member
initial_count = PearsonRTENEvent.objects.filter(event_type=self.event_type).count()
payload = {
"eventType": "RESULT_AVAILABLE",
"candidate": {
"candidateName": {
"firstName": "Alastor",
"lastName": "Moody",
}
}
}

response = self.client.post(reverse(f"pearson-vue-api:v1:{self.event_type}"), payload, format="json")

final_count = PearsonRTENEvent.objects.filter(event_type=self.event_type).count()

self.assertEqual(final_count, initial_count + 1)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {})
result_notification_mock.assert_called_once_with(request_data=payload)
result_notification_mock.return_value.run_pipeline.assert_called_once()


class TestPlaceHoldView(RTENMixin, unittest.TestCase):
"""
Expand Down
Loading

0 comments on commit 65c41a0

Please sign in to comment.