diff --git a/lms/djangoapps/learner_recommendations/serializers.py b/lms/djangoapps/learner_recommendations/serializers.py index 765bbc5d54..af91e3bf06 100644 --- a/lms/djangoapps/learner_recommendations/serializers.py +++ b/lms/djangoapps/learner_recommendations/serializers.py @@ -39,6 +39,24 @@ class RecommendedCourseSerializer(serializers.Serializer): return f"course/{url_slug}" +class CrossProductCourseSerializer(serializers.Serializer): + """Serializer for a cross product recommended course""" + key = serializers.CharField() + uuid = serializers.UUIDField() + title = serializers.CharField() + image = CourseImageSerializer() + prospectusPath = serializers.SerializerMethodField() + owners = serializers.ListField( + child=CourseOwnersSerializer(), allow_empty=True + ) + activeCourseRun = ActiveCourseRunSerializer(source="active_course_run") + courseType = serializers.CharField(source="course_type") + + def get_prospectusPath(self, instance): + url_slug = instance.get("url_slug") + return f"course/{url_slug}" + + class AboutPageRecommendationsSerializer(serializers.Serializer): """Recommended courses for course about page""" @@ -51,6 +69,13 @@ class AboutPageRecommendationsSerializer(serializers.Serializer): ) +class CrossProductRecommendationsSerializer(serializers.Serializer): + """Cross product recommendation courses for course about page""" + courses = serializers.ListField( + child=CrossProductCourseSerializer(), allow_empty=True + ) + + class CourseSerializer(serializers.Serializer): """Serializer for a recommended course from the recommendation engine""" diff --git a/lms/djangoapps/learner_recommendations/tests/test_data.py b/lms/djangoapps/learner_recommendations/tests/test_data.py new file mode 100644 index 0000000000..f97c807f90 --- /dev/null +++ b/lms/djangoapps/learner_recommendations/tests/test_data.py @@ -0,0 +1,53 @@ +""" Mocked data for testing """ + +mock_course_data = { + "courses": [ + { + "key": "edx+HL0", + "uuid": "0f8cb2c9-589b-4d1e-88c1-b01a02db3a9c", + "title": "Title 0", + "image": { + "src": "https://www.logo_image_url0.com" + }, + "prospectusPath": "course/https://www.marketing_url0.com", + "owners": [ + { + "key": "org-0", + "name": "org 0", + "logoImageUrl": "https://discovery.com/organization/logos/org-0.png" + } + ], + "activeCourseRun": { + "key": "course-v1:Test+2023_T0", + "marketingUrl": "https://www.marketing_url0.com" + }, + "courseType": "executive-education" + }, + { + "key": "edx+HL1", + "uuid": "1f8cb2c9-589b-4d1e-88c1-b01a02db3a9c", + "title": "Title 1", + "image": { + "src": "https://www.logo_image_url1.com" + }, + "prospectusPath": "course/https://www.marketing_url1.com", + "owners": [ + { + "key": "org-1", + "name": "org 1", + "logoImageUrl": "https://discovery.com/organization/logos/org-1.png" + } + ], + "activeCourseRun": { + "key": "course-v1:Test+2023_T1", + "marketingUrl": "https://www.marketing_url1.com" + }, + "courseType": "executive-education" + } + ] +} + +mock_cross_product_recommendation_keys = { + "edx+HL0": ["edx+HL1", "edx+HL2"], + "edx+BZ0": ["edx+BZ1", "edx+BZ2"], +} diff --git a/lms/djangoapps/learner_recommendations/tests/test_serializers.py b/lms/djangoapps/learner_recommendations/tests/test_serializers.py index ff66cadb25..bd6fdd8fef 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_serializers.py +++ b/lms/djangoapps/learner_recommendations/tests/test_serializers.py @@ -6,7 +6,9 @@ from django.test import TestCase from lms.djangoapps.learner_recommendations.serializers import ( DashboardRecommendationsSerializer, + CrossProductRecommendationsSerializer ) +from lms.djangoapps.learner_recommendations.tests.test_data import mock_course_data class TestDashboardRecommendationsSerializer(TestCase): @@ -81,3 +83,72 @@ class TestDashboardRecommendationsSerializer(TestCase): "isControl": False, }, ) + + +class TestCrossProductRecommendationsSerializer(TestCase): + """Tests for the Cross Product Recommendations Serializer""" + + def mock_recommended_courses(self, num_of_courses): + """Course data mock""" + + recommended_courses = [] + + for index in range(num_of_courses): + recommended_courses.append( + { + "key": f"edx+HL{index}", + "uuid": f"{index}f8cb2c9-589b-4d1e-88c1-b01a02db3a9c", + "title": f"Title {index}", + "image": { + "src": f"https://www.logo_image_url{index}.com", + }, + "url_slug": f"https://www.marketing_url{index}.com", + "course_type": "executive-education", + "owners": [ + { + "key": f"org-{index}", + "name": f"org {index}", + "logo_image_url": f"https://discovery.com/organization/logos/org-{index}.png", + }, + ], + "course_runs": [ + { + "key": f"course-v1:Test+2023_T{index}", + "marketing_url": f"https://www.marketing_url{index}.com", + "availability": "Current", + } + ], + "active_course_run": { + "key": f"course-v1:Test+2023_T{index}", + "marketing_url": f"https://www.marketing_url{index}.com", + "availability": "Current", + }, + "location_restriction": None + }, + ) + + return recommended_courses + + def test_successful_serialization(self): + courses = self.mock_recommended_courses(num_of_courses=2) + + serialized_data = CrossProductRecommendationsSerializer({ + "courses": courses + }).data + + self.assertDictEqual( + serialized_data, + mock_course_data + ) + + def test_no_course_data_serialization(self): + serialized_data = CrossProductRecommendationsSerializer({ + "courses": [] + }).data + + self.assertDictEqual( + serialized_data, + { + "courses": [] + }, + ) diff --git a/lms/djangoapps/learner_recommendations/tests/test_utils.py b/lms/djangoapps/learner_recommendations/tests/test_utils.py index b1a9a7b48c..9aa76b7e81 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_utils.py +++ b/lms/djangoapps/learner_recommendations/tests/test_utils.py @@ -11,8 +11,10 @@ from lms.djangoapps.learner_recommendations.utils import ( _has_country_restrictions, filter_recommended_courses, get_amplitude_course_recommendations, + get_cross_product_recommendations ) from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from lms.djangoapps.learner_recommendations.tests.test_data import mock_cross_product_recommendation_keys @ddt.ddt @@ -209,3 +211,18 @@ class TestFilterRecommendedCourses(ModuleStoreTestCase): expected_recommendations.append(self._mock_get_course_data(course_key)) assert filtered_courses == expected_recommendations + + +@ddt.ddt +class TestGetCrossProductRecommendationsMethod(TestCase): + """Test for get_cross_product_recommendations method""" + + @ddt.data( + ("edx+HL0", ["edx+HL1", "edx+HL2"]), + ("edx+BZ0", ["edx+BZ1", "edx+BZ2"]), + ('NoKeyAssociated', None) + ) + @patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @ddt.unpack + def test_get_cross_product_recommendations_method(self, course_key, expected_response): + assert get_cross_product_recommendations(course_key) == expected_response diff --git a/lms/djangoapps/learner_recommendations/tests/test_views.py b/lms/djangoapps/learner_recommendations/tests/test_views.py index 1d3aaf79a0..d582eaf334 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_views.py +++ b/lms/djangoapps/learner_recommendations/tests/test_views.py @@ -15,10 +15,12 @@ from lms.djangoapps.learner_recommendations.toggles import ( ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS, ENABLE_DASHBOARD_RECOMMENDATIONS, ) +from lms.djangoapps.learner_recommendations.tests.test_data import mock_cross_product_recommendation_keys class TestRecommendationsBase(APITestCase): """Recommendations test base class""" + def setUp(self): super().setUp() self.user = UserFactory() @@ -152,6 +154,161 @@ class TestAboutPageRecommendationsView(TestRecommendationsBase): assert segment_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed" +class TestCrossProductRecommendationsView(APITestCase): + """Unit tests for the Cross Product Recommendations View""" + + def setUp(self): + super().setUp() + self.associated_course_keys = ["edx+HL1", "edx+HL2"] + + def _get_url(self, course_key): + """ + Returns the url with a sepcific course id + """ + return reverse_lazy( + "learner_recommendations:cross_product_recommendations", + kwargs={'course_id': f'course-v1:{course_key}+Test_Course'} + ) + + def _get_recommended_courses(self, num_of_courses_with_restriction=0): + """ + Returns an array of 2 discovery courses with or without country restrictions + """ + courses = [] + restriction_obj = { + "restriction_type": "blocklist", + "countries": ["CN"], + "states": [] + } + + for course_key in enumerate(self.associated_course_keys): + location_restriction = restriction_obj if num_of_courses_with_restriction > 0 else None + + courses.append({ + "key": course_key[1], + "uuid": "6f8cb2c9-589b-4d1e-88c1-b01a02db3a9c", + "title": f"Title {course_key[0]}", + "image": { + "src": "https://www.logo_image_url.com", + }, + "url_slug": "https://www.marketing_url.com", + "course_type": "executive-education", + "owners": [ + { + "key": "org-1", + "name": "org 1", + "logo_image_url": "https://discovery.com/organization/logos/org-1.png", + }, + ], + "course_runs": [ + { + "key": "course-v1:Test+2023_T2", + "marketing_url": "https://www.marketing_url.com", + "availability": "Current", + } + ], + "location_restriction": location_restriction + }) + + if num_of_courses_with_restriction > 0: + num_of_courses_with_restriction -= 1 + + return courses + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_successful_response( + self, country_code_from_ip_mock, get_course_data_mock, + ): + """ + Verify 2 cross product course recommendations are returned. + """ + country_code_from_ip_mock.return_value = "za" + mock_course_data = self._get_recommended_courses() + get_course_data_mock.side_effect = [mock_course_data[0], mock_course_data[1]] + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + course_data = response_content["courses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(course_data), 2) + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_one_course_country_restriction_response( + self, country_code_from_ip_mock, get_course_data_mock, + ): + """ + Verify 1 cross product course recommendation is returned + if there is a location restriction for one course for the users country + """ + country_code_from_ip_mock.return_value = "cn" + mock_course_data = self._get_recommended_courses(1) + get_course_data_mock.side_effect = [mock_course_data[0], mock_course_data[1]] + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + course_data = response_content["courses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(course_data), 1) + self.assertEqual(course_data[0]["title"], "Title 1") + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_both_course_country_restriction_response( + self, country_code_from_ip_mock, get_course_data_mock, + ): + """ + Verify no courses are returned if both courses have a location restriction + for the users country. + """ + country_code_from_ip_mock.return_value = "cn" + mock_course_data = self._get_recommended_courses(2) + + get_course_data_mock.side_effect = [mock_course_data[0], mock_course_data[1]] + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + course_data = response_content["courses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(course_data), 0) + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + def test_no_associated_course_response(self): + """ + Verify an empty array of courses is returned if there are no associated course keys. + """ + response = self.client.get(self._get_url('No+Associations')) + response_content = json.loads(response.content) + course_data = response_content["courses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(course_data), 0) + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_no_response_from_discovery(self, country_code_from_ip_mock, get_course_data_mock): + """ + Verify an empty array of courses is returned if discovery returns two empty dictionaries. + """ + country_code_from_ip_mock.return_value = "za" + get_course_data_mock.side_effect = [{}, {}] + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + course_data = response_content["courses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(course_data), 0) + + @ddt.ddt class TestDashboardRecommendationsApiView(TestRecommendationsBase): """Unit tests for the course recommendations on learner home page.""" @@ -251,7 +408,6 @@ class TestDashboardRecommendationsApiView(TestRecommendationsBase): """ Test that if the Amplitude API gives an unexpected error, general recommendations are returned. """ - response = self.client.get(self.url) self.assertEqual(response.status_code, 200) diff --git a/lms/djangoapps/learner_recommendations/urls.py b/lms/djangoapps/learner_recommendations/urls.py index c934cf0742..5b21c38662 100644 --- a/lms/djangoapps/learner_recommendations/urls.py +++ b/lms/djangoapps/learner_recommendations/urls.py @@ -13,7 +13,10 @@ urlpatterns = [ re_path(fr'^amplitude/{settings.COURSE_ID_PATTERN}/$', views.AboutPageRecommendationsView.as_view(), name='amplitude_recommendations'), + re_path(fr'^cross_product/{settings.COURSE_ID_PATTERN}/$', + views.CrossProductRecommendationsView.as_view(), + name='cross_product_recommendations'), re_path(r"^courses/$", views.DashboardRecommendationsApiView.as_view(), - name="courses"), + name="courses") ] diff --git a/lms/djangoapps/learner_recommendations/utils.py b/lms/djangoapps/learner_recommendations/utils.py index b6bcb1ce35..20f58d743e 100644 --- a/lms/djangoapps/learner_recommendations/utils.py +++ b/lms/djangoapps/learner_recommendations/utils.py @@ -202,3 +202,10 @@ def filter_recommended_courses( filtered_recommended_courses.append(course_data) return filtered_recommended_courses + + +def get_cross_product_recommendations(course_key): + """ + Helper method to get associated course keys based on the key passed + """ + return settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS.get(course_key) diff --git a/lms/djangoapps/learner_recommendations/views.py b/lms/djangoapps/learner_recommendations/views.py index a969fe86e0..f060b5c4a9 100644 --- a/lms/djangoapps/learner_recommendations/views.py +++ b/lms/djangoapps/learner_recommendations/views.py @@ -10,6 +10,7 @@ from edx_rest_framework_extensions.auth.session.authentication import ( SessionAuthenticationAllowInactiveUser, ) from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from opaque_keys.edx.keys import CourseKey from django.core.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -27,13 +28,16 @@ from lms.djangoapps.learner_recommendations.utils import ( get_amplitude_course_recommendations, filter_recommended_courses, is_user_enrolled_in_ut_austin_masters_program, + _has_country_restrictions, ) +from lms.djangoapps.learner_recommendations.serializers import CrossProductRecommendationsSerializer +from lms.djangoapps.learner_recommendations.utils import get_cross_product_recommendations +from openedx.core.djangoapps.catalog.utils import get_course_data from lms.djangoapps.learner_recommendations.serializers import ( AboutPageRecommendationsSerializer, DashboardRecommendationsSerializer, ) - log = logging.getLogger(__name__) @@ -124,6 +128,69 @@ class AboutPageRecommendationsView(APIView): ) +class CrossProductRecommendationsView(APIView): + """ + **Example Request** + + GET api/learner_recommendations/cross_product/{course_id}/ + """ + + def _empty_response(self): + return Response({"courses": []}, status=200) + + def get(self, request, course_id): + """ + Returns cross product recommendation courses + """ + course_locator = CourseKey.from_string(course_id) + course_key = f'{course_locator.org}+{course_locator.course}' + + associated_course_keys = get_cross_product_recommendations(course_key) + + if not associated_course_keys: + return self._empty_response() + + fields = [ + "key", + "uuid", + "title", + "owners", + "image", + "url_slug", + "course_type", + "course_runs", + "location_restriction", + ] + query_string = {'marketable_course_runs_only': 1} + course_data = [get_course_data(key, fields, query_string) for key in associated_course_keys] + filtered_courses = [course for course in course_data if course and course.get("course_runs")] + + ip_address = get_client_ip(request)[0] + user_country_code = country_code_from_ip(ip_address).upper() + + unrestricted_courses = [] + + for course in filtered_courses: + if not _has_country_restrictions(course, user_country_code): + unrestricted_courses.append(course) + + if not unrestricted_courses: + return self._empty_response() + + for course in unrestricted_courses: + course.update({ + "active_course_run": course.get("course_runs")[0] + }) + + return Response( + CrossProductRecommendationsSerializer( + { + "courses": unrestricted_courses + }).data, + status=200 + ) + + class DashboardRecommendationsApiView(APIView): """ API to get personalized recommendations from Amplitude. diff --git a/lms/envs/common.py b/lms/envs/common.py index 59730af797..6d2e67f874 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4789,6 +4789,9 @@ GENERAL_RECOMMENDATION = {} GENERAL_RECOMMENDATIONS = [] +### DEFAULT KEY DICTIONARY FOR CROSS_PRODUCT_RECOMMENDATIONS ### +CROSS_PRODUCT_RECOMMENDATIONS_KEYS = {} + ############### Settings for Retirement ##################### # .. setting_name: RETIRED_USERNAME_PREFIX # .. setting_default: retired__user_