From 4a67ba312fd706033451298cad6b9a679d6d4013 Mon Sep 17 00:00:00 2001 From: andrey-canon Date: Mon, 8 Jul 2024 20:09:36 -0500 Subject: [PATCH] feat: add course overview field to RTEN model --- .../0011_pearsonrtenevent_course.py | 33 ++++++++++ eox_nelp/pearson_vue/admin.py | 4 +- eox_nelp/pearson_vue/api/v1/serializers.py | 20 +++++- eox_nelp/pearson_vue/api/v1/views.py | 30 ++++++++- eox_nelp/pearson_vue/models.py | 2 + .../pearson_vue/tests/api/v1/test_views.py | 64 +++++++++++++++++-- 6 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 eox_nelp/migrations/0011_pearsonrtenevent_course.py diff --git a/eox_nelp/migrations/0011_pearsonrtenevent_course.py b/eox_nelp/migrations/0011_pearsonrtenevent_course.py new file mode 100644 index 00000000..658ca522 --- /dev/null +++ b/eox_nelp/migrations/0011_pearsonrtenevent_course.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.21 on 2024-07-08 17:29 + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0027_auto_20221102_1109'), + ('eox_nelp', '0010_pearsonrtenevent_candidate'), + ] + + if getattr(settings, 'TESTING_MIGRATIONS', False): + dependencies = [ + ('eox_nelp', '0010_pearsonrtenevent_candidate'), + ] + course_overview_model = 'eox_nelp.courseoverview' + else: + dependencies = [ + ('eox_nelp', '0010_pearsonrtenevent_candidate'), + ('course_overviews', '0025_auto_20210702_1602'), + ] + course_overview_model = 'course_overviews.courseoverview' + + operations = [ + migrations.AddField( + model_name='pearsonrtenevent', + name='course', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to=course_overview_model), + ), + ] diff --git a/eox_nelp/pearson_vue/admin.py b/eox_nelp/pearson_vue/admin.py index 24c45b0a..c42cc5d4 100644 --- a/eox_nelp/pearson_vue/admin.py +++ b/eox_nelp/pearson_vue/admin.py @@ -22,8 +22,8 @@ class PearsonRTENEventAdmin(admin.ModelAdmin): list_display (list): List of fields to display in the admin list view. readonly_fields (tuple): Tuple of fields that are read-only in the admin interface. """ - list_display = ("event_type", "created_at", "candidate") - readonly_fields = ("created_at", "candidate", "event_type") + list_display = ("event_type", "candidate", "course", "created_at") + readonly_fields = ("created_at", "candidate", "course", "event_type") list_filter = ["event_type"] diff --git a/eox_nelp/pearson_vue/api/v1/serializers.py b/eox_nelp/pearson_vue/api/v1/serializers.py index ebc6f490..cb21c80d 100644 --- a/eox_nelp/pearson_vue/api/v1/serializers.py +++ b/eox_nelp/pearson_vue/api/v1/serializers.py @@ -28,13 +28,13 @@ class PearsonRTENSerializer(serializers.ModelSerializer): Attributes: content (serializers.JSONField): A field to handle JSON content. """ - + course = serializers.SerializerMethodField() content = serializers.JSONField() class Meta: """Meta class""" model = PearsonRTENEvent - fields = ["event_type", "content", "candidate", "created_at"] + fields = ["event_type", "content", "candidate", "course", "created_at"] read_only_fields = ["event_type", "created_at"] def to_internal_value(self, data): @@ -48,3 +48,19 @@ def to_internal_value(self, data): dict: A dictionary containing the serialized data. """ return {"content": data} + + def get_course(self, obj): + """ + Retrieves the course associated with the given object as a string. + + This method checks if the `course` attribute of the provided object exists. + If it does, it returns the string representation of the course. + Otherwise, it returns `None`. + + Args: + obj (object): The object containing the `course` attribute. + + Returns: + str or None: The string representation of the course if it exists, otherwise `None`. + """ + return str(obj.course) if obj.course else None diff --git a/eox_nelp/pearson_vue/api/v1/views.py b/eox_nelp/pearson_vue/api/v1/views.py index 481b513f..b1f2f4d6 100644 --- a/eox_nelp/pearson_vue/api/v1/views.py +++ b/eox_nelp/pearson_vue/api/v1/views.py @@ -35,6 +35,7 @@ UNREVOKE_RESULT, ) from eox_nelp.pearson_vue.models import PearsonRTENEvent +from eox_nelp.pearson_vue.pipeline import get_enrollment_from_id from eox_nelp.pearson_vue.rti_backend import ResultNotificationBackend @@ -103,7 +104,12 @@ def perform_create(self, serializer): serializer (Serializer): The serializer instance used for data validation and saving. """ content_data = self.request.data.copy() - serializer.save(event_type=self.event_type, candidate=self.get_candidate(), content=content_data) + serializer.save( + event_type=self.event_type, + candidate=self.get_candidate(), + content=content_data, + course=self.get_course(), + ) def create(self, request, *args, **kwargs): """ @@ -142,6 +148,28 @@ def get_candidate(self): except AnonymousUserId.DoesNotExist: return None + def get_course(self): + """ + Retrieves the course associated with the enrollment ID from the request data. + + This method extracts the `clientAuthorizationID` from the request data. If the ID is present, + it splits the ID to obtain the `enrollment_id` and then retrieves the enrollment object using + the `get_enrollment_from_id` function. If the enrollment object exists, it returns the associated course. + Otherwise, it returns `None`. + + Returns: + Course or None: The course associated with the enrollment if it exists, otherwise `None`. + """ + client_authorization_id = self.request.data.get("authorization", {}).get("clientAuthorizationID") + + if not client_authorization_id: + return None + + enrollment_id = client_authorization_id.split("-")[0] + enrollment = get_enrollment_from_id(enrollment_id).get("enrollment") + + return enrollment.course if enrollment else None + class ResultNotificationView(PearsonRTENBaseView): """ diff --git a/eox_nelp/pearson_vue/models.py b/eox_nelp/pearson_vue/models.py index 118bd3eb..a9ed6361 100644 --- a/eox_nelp/pearson_vue/models.py +++ b/eox_nelp/pearson_vue/models.py @@ -13,6 +13,7 @@ from django.contrib.auth import get_user_model from django.db import models +from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview from eox_nelp.pearson_vue.constants import ( CANCEL_APPOINTMENT, MODIFY_APPOINTMENT, @@ -50,3 +51,4 @@ class PearsonRTENEvent(models.Model): created_at = models.DateTimeField(auto_now_add=True) event_type = models.CharField(max_length=20, choices=EVENT_TYPE_CHOICES) candidate = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + course = models.ForeignKey(CourseOverview, null=True, on_delete=models.DO_NOTHING) diff --git a/eox_nelp/pearson_vue/tests/api/v1/test_views.py b/eox_nelp/pearson_vue/tests/api/v1/test_views.py index 36352a0e..573add55 100644 --- a/eox_nelp/pearson_vue/tests/api/v1/test_views.py +++ b/eox_nelp/pearson_vue/tests/api/v1/test_views.py @@ -11,15 +11,17 @@ TestUnrevokeResultView: Unit tests for the UnrevokeResultView. """ import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist from django.test import override_settings from django.urls import reverse +from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.test import APIClient +from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview from eox_nelp.edxapp_wrapper.student import AnonymousUserId from eox_nelp.pearson_vue.constants import ( CANCEL_APPOINTMENT, @@ -49,6 +51,8 @@ def setUp(self): # pylint: disable=invalid-name self.client = APIClient() self.user, _ = User.objects.get_or_create(username='testuser', password='12345') self.client.force_authenticate(user=self.user) + self.course_key = CourseKey.from_string("course-v1:test+CS501+2022_T4") + self.course, _ = CourseOverview.objects.get_or_create(id=self.course_key) def tearDown(self): # pylint: disable=invalid-name """ @@ -57,8 +61,9 @@ def tearDown(self): # pylint: disable=invalid-name AnonymousUserId.reset_mock() AnonymousUserId.objects.get.side_effect = None + @patch("eox_nelp.pearson_vue.api.v1.views.get_enrollment_from_id") @override_settings(ENABLE_CERTIFICATE_PUBLISHER=False) - def test_create_result_notification_event(self): + def test_create_result_notification_event(self, enrollment_from_id_mock): """ Test creating an event. @@ -67,23 +72,39 @@ def test_create_result_notification_event(self): - Response returns a 200 status code. - Response data is empty. - AnonymousUserId.objects.get has been called with the expected data. + - get_enrollment_from_id has been called with the expected data. """ # pylint: disable=no-member - initial_count = PearsonRTENEvent.objects.filter(event_type=self.event_type, candidate=self.user).count() + initial_count = PearsonRTENEvent.objects.filter( + event_type=self.event_type, + candidate=self.user, + course=self.course, + ).count() + enrollment_from_id_mock.return_value = {"enrollment": Mock(course=self.course)} AnonymousUserId.objects.get.return_value.user = self.user response = self.client.post( reverse(f"pearson-vue-api:v1:{self.event_type}"), - {"clientCandidateID": "NELC123456"}, + { + "clientCandidateID": "NELC123456", + "authorization": { + "clientAuthorizationID": "1584-4785" + }, + }, format="json", ) - final_count = PearsonRTENEvent.objects.filter(event_type=self.event_type, candidate=self.user).count() + final_count = PearsonRTENEvent.objects.filter( + event_type=self.event_type, + candidate=self.user, + course=self.course, + ).count() self.assertEqual(final_count, initial_count + 1) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, {}) AnonymousUserId.objects.get.assert_called_once_with(anonymous_user_id="123456") + enrollment_from_id_mock.assert_called_once_with("1584") @override_settings(ENABLE_CERTIFICATE_PUBLISHER=False) def test_create_result_notification_event_without_user(self): @@ -110,6 +131,39 @@ def test_create_result_notification_event_without_user(self): self.assertEqual(response.data, {}) AnonymousUserId.objects.get.assert_called_once_with(anonymous_user_id="") + @patch("eox_nelp.pearson_vue.api.v1.views.get_enrollment_from_id") + @override_settings(ENABLE_CERTIFICATE_PUBLISHER=False) + def test_create_result_notification_event_with_invalid_authorization_id(self, enrollment_from_id_mock): + """ + Test creating an event with invalid clienAuthorizationID. + + Expected behavior: + - The number of records has increased in 1. + - Response returns a 200 status code. + - Response data is empty. + - the course record is None + - get_enrollment_from_id has been called with the expected data. + """ + # pylint: disable=no-member + initial_record_ids = list( + PearsonRTENEvent.objects.filter(event_type=self.event_type).values_list('id', flat=True) + ) + enrollment_from_id_mock.return_value = {} + + response = self.client.post( + reverse(f"pearson-vue-api:v1:{self.event_type}"), + {"authorization": {"clientAuthorizationID": "1584-4785"}}, + format="json", + ) + + new_records = PearsonRTENEvent.objects.filter(event_type=self.event_type).exclude(id__in=initial_record_ids) + + self.assertEqual(1, new_records.count()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {}) + self.assertIsNone(new_records[0].course) + enrollment_from_id_mock.assert_called_once_with("1584") + def test_get_event(self): """ Test retrieving an event.