From c7fc04968f37b252a427e42848c00e30335b15e4 Mon Sep 17 00:00:00 2001 From: Jody Bailey <110463597+JodyBaileyy@users.noreply.github.com> Date: Mon, 5 Jun 2023 16:31:11 +0200 Subject: [PATCH] feat: new endpoint for cross product and amplitude recommendations (#32297) * feat: cross product recommendations endpoint enhancement for amplitude recommendations * fix: general recommendations fix and linting fixes * fix: query string parameter value check fix * chore: updated positioning of docstring * fix: Adjusted docstring for CPR serializer * fix: Separated view, added new url, fallback recommendations fixes and more * fix: removed dangerous default value argument * chore: made necessary linting changes * chore: updated doctring description * fix: removed dangerous default argument * chore: updated doctring for ProductRecommendationsView --- .../learner_recommendations/serializers.py | 38 ++- .../tests/test_data.py | 192 ++++++++--- .../tests/test_serializers.py | 74 ++++- .../tests/test_views.py | 311 +++++++++++++++++- .../learner_recommendations/urls.py | 5 +- .../learner_recommendations/utils.py | 4 +- .../learner_recommendations/views.py | 105 +++++- 7 files changed, 663 insertions(+), 66 deletions(-) diff --git a/lms/djangoapps/learner_recommendations/serializers.py b/lms/djangoapps/learner_recommendations/serializers.py index af91e3bf06..360df532f9 100644 --- a/lms/djangoapps/learner_recommendations/serializers.py +++ b/lms/djangoapps/learner_recommendations/serializers.py @@ -39,8 +39,8 @@ class RecommendedCourseSerializer(serializers.Serializer): return f"course/{url_slug}" -class CrossProductCourseSerializer(serializers.Serializer): - """Serializer for a cross product recommended course""" +class AboutPageProductRecommendationsSerializer(serializers.Serializer): + """Serializer for a cross product recommended course for the course about page""" key = serializers.CharField() uuid = serializers.UUIDField() title = serializers.CharField() @@ -57,6 +57,21 @@ class CrossProductCourseSerializer(serializers.Serializer): return f"course/{url_slug}" +class LearnerDashboardProductRecommendationsSerializer(serializers.Serializer): + """Serializer for product recommendations for the Learner Dashboard""" + title = serializers.CharField() + image = CourseImageSerializer() + prospectusPath = serializers.SerializerMethodField() + owners = serializers.ListField( + child=CourseOwnersSerializer(), allow_empty=True + ) + 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""" @@ -70,9 +85,24 @@ class AboutPageRecommendationsSerializer(serializers.Serializer): class CrossProductRecommendationsSerializer(serializers.Serializer): - """Cross product recommendation courses for course about page""" + """ + Cross product recommendation courses for course about page + """ courses = serializers.ListField( - child=CrossProductCourseSerializer(), allow_empty=True + child=AboutPageProductRecommendationsSerializer(), allow_empty=True + ) + + +class CrossProductAndAmplitudeRecommendationsSerializer(serializers.Serializer): + """ + Cross product recommendation courses and + Amplitude recommendations for Learner Dashboard + """ + crossProductCourses = serializers.ListField( + child=LearnerDashboardProductRecommendationsSerializer(), allow_empty=True + ) + amplitudeCourses = serializers.ListField( + child=LearnerDashboardProductRecommendationsSerializer(), allow_empty=True ) diff --git a/lms/djangoapps/learner_recommendations/tests/test_data.py b/lms/djangoapps/learner_recommendations/tests/test_data.py index f97c807f90..9fc950115c 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_data.py +++ b/lms/djangoapps/learner_recommendations/tests/test_data.py @@ -1,50 +1,154 @@ """ 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" +mock_course_data = [ + { + "key": "edx+HL0", + "uuid": "0f8cb2c9-589b-4d1e-88c1-b01a02db3a9c", + "title": "Title 0", + "image": { + "src": "https://www.logo_image_url0.com" }, - { - "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" - } - ] + "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_data = [ + { + "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" + } + ], + "courseType": "executive-education" + }, + { + "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" + } + ], + "courseType": "executive-education" + }, +] + +mock_amplitude_data = [ + *mock_cross_product_data, + { + "title": "Title 2", + "image": { + "src": "https://www.logo_image_url2.com" + }, + "prospectusPath": "course/https://www.marketing_url2.com", + "owners": [ + { + "key": "org-2", + "name": "org 2", + "logoImageUrl": "https://discovery.com/organization/logos/org-2.png" + } + ], + "courseType": "executive-education" + }, + { + "title": "Title 3", + "image": { + "src": "https://www.logo_image_url3.com" + }, + "prospectusPath": "course/https://www.marketing_url3.com", + "owners": [ + { + "key": "org-3", + "name": "org 3", + "logoImageUrl": "https://discovery.com/organization/logos/org-3.png" + } + ], + "courseType": "executive-education" + } +] + + +def get_general_recommendations(): + """Returns 5 general recommendations with the necessary fields""" + + courses = [] + + base_course = { + "course_key": "MITx+1.00", + "title": "Introduction to Computer Science and Programming Using Python", + "url_slug": "introduction-to-computer-science-and-programming-7", + "course_type": "credit-verified-audit", + "logo_image_url": "https://discovery.com/organization/logos/org-1.png", + "marketing_url": "https://www.marketing_url.com", + "owners": [ + { + "key": "MITx", + "name": "Massachusetts Institute of Technology", + "logo_image_url": "https://discovery.com/organization/logos/org-1.png", + } + ], + "image": { + "src": "https://link.to.an.image.png" + }, + } + + for _ in range(5): + courses.append(base_course) + + return courses + +mock_amplitude_and_cross_product_course_data = { + "crossProductCourses": mock_cross_product_data, + "amplitudeCourses": mock_amplitude_data +} + +mock_cross_product_course_data = { + "courses": mock_course_data } mock_cross_product_recommendation_keys = { diff --git a/lms/djangoapps/learner_recommendations/tests/test_serializers.py b/lms/djangoapps/learner_recommendations/tests/test_serializers.py index bd6fdd8fef..0d905b6952 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_serializers.py +++ b/lms/djangoapps/learner_recommendations/tests/test_serializers.py @@ -6,9 +6,13 @@ from django.test import TestCase from lms.djangoapps.learner_recommendations.serializers import ( DashboardRecommendationsSerializer, - CrossProductRecommendationsSerializer + CrossProductRecommendationsSerializer, + CrossProductAndAmplitudeRecommendationsSerializer +) +from lms.djangoapps.learner_recommendations.tests.test_data import ( + mock_amplitude_and_cross_product_course_data, + mock_cross_product_course_data ) -from lms.djangoapps.learner_recommendations.tests.test_data import mock_course_data class TestDashboardRecommendationsSerializer(TestCase): @@ -85,10 +89,13 @@ class TestDashboardRecommendationsSerializer(TestCase): ) -class TestCrossProductRecommendationsSerializer(TestCase): - """Tests for the Cross Product Recommendations Serializer""" +class TestCrossProductRecommendationsSerializers(TestCase): + """ + Tests for the CrossProductRecommendationsSerializer + and CrossProductAndAmplitudeRecommendations Serializer + """ - def mock_recommended_courses(self, num_of_courses): + def mock_recommended_courses(self, num_of_courses=2, amplitude_courses=False): """Course data mock""" recommended_courses = [] @@ -127,28 +134,73 @@ class TestCrossProductRecommendationsSerializer(TestCase): }, ) + if amplitude_courses: + keys_to_remove = ["active_course_run", "key", "uuid"] + amplitude_courses = [] + + for course in recommended_courses: + new_course = {key: value for key, value in course.items() if key not in keys_to_remove} + amplitude_courses.append(new_course) + + return amplitude_courses + return recommended_courses - def test_successful_serialization(self): + def test_successful_cross_product_recommendation_serialization(self): + """Test that course data serializes correctly for CrossProductRecommendationSerializer""" courses = self.mock_recommended_courses(num_of_courses=2) serialized_data = CrossProductRecommendationsSerializer({ - "courses": courses + "courses": courses, }).data self.assertDictEqual( serialized_data, - mock_course_data + mock_cross_product_course_data ) - def test_no_course_data_serialization(self): + def test_successful_cross_product_and_amplitude_recommendations_serializer(self): + """Test that course data serializes correctly for CrossProductAndAmplitudeRecommendationSerializer""" + + cross_product_courses = self.mock_recommended_courses(num_of_courses=2) + amplitude_courses = self.mock_recommended_courses(num_of_courses=4, amplitude_courses=True) + + serialized_data = CrossProductAndAmplitudeRecommendationsSerializer({ + "crossProductCourses": cross_product_courses, + "amplitudeCourses": amplitude_courses, + }).data + + self.assertDictEqual( + serialized_data, + mock_amplitude_and_cross_product_course_data + ) + + def test_no_cross_product_course_serialization(self): + """Tests that empty course data for CrossProductRecommendationsSerializer serializes properly""" + serialized_data = CrossProductRecommendationsSerializer({ - "courses": [] + "courses": [], }).data self.assertDictEqual( serialized_data, { - "courses": [] + "courses": [], + }, + ) + + def test_no_amplitude_and_cross_product_and_course_serialization(self): + """Tests that empty course data for CrossProductRecommendationsSerializer serializes properly""" + + serialized_data = CrossProductAndAmplitudeRecommendationsSerializer({ + "crossProductCourses": [], + "amplitudeCourses": [] + }).data + + self.assertDictEqual( + serialized_data, + { + "crossProductCourses": [], + "amplitudeCourses": [] }, ) diff --git a/lms/djangoapps/learner_recommendations/tests/test_views.py b/lms/djangoapps/learner_recommendations/tests/test_views.py index 97789ad71f..9fed293f73 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_views.py +++ b/lms/djangoapps/learner_recommendations/tests/test_views.py @@ -15,7 +15,10 @@ 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 +from lms.djangoapps.learner_recommendations.tests.test_data import ( + mock_cross_product_recommendation_keys, + get_general_recommendations +) class TestRecommendationsBase(APITestCase): @@ -331,6 +334,312 @@ class TestCrossProductRecommendationsView(APITestCase): self.assertEqual(len(course_data), 0) +class TestProductRecommendationsView(APITestCase): + """Unit tests for ProductRecommendations View""" + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.client.login(username=self.user.username, password="test") + self.associated_course_keys = ["edx+HL1", "edx+HL2"] + self.amplitude_keys = [ + "edx+CS0", + "edx+CS10", + "edx+CS20", + "edx+CS30", + "edx+CS40", + "edx+CS50", + "edx+CS60", + "edx+CS70", + "edx+CS80", + "edx+CS90", + ] + self.amplitude_course_run_keys = [f"course-v1:{course_key}+2023_T2" for course_key in self.amplitude_keys] + self.enrolled_course_run_keys = self.amplitude_course_run_keys[3:8] + self.enrolled_course_keys = self.amplitude_keys[3:8] + self.amplitude_location_restriction_keys = self.amplitude_keys[0:3] + self.cross_product_location_restriction_keys = self.associated_course_keys[0] + + def _get_url(self, course_key): + """ + Returns the url with a sepcific course id + """ + return reverse_lazy( + "learner_recommendations:product_recommendations", + kwargs={'course_id': f'course-v1:{course_key}+Test_Course'} + ) + + def _get_product_recommendations(self, course_keys, keys_with_restriction=None): + """ + Returns course data based on the number of course keys passed in + with a location restriction object if a list of keys for location restriction courses is passed in + """ + courses = [] + + for key in course_keys: + course = { + "title": f"Title for {key}", + "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": f"course-v1:{key}+2023_T2", + "marketing_url": "https://www.marketing_url.com", + "availability": "Current", + "uuid": "jh76b2c9-589b-4d1e-88c1-b01a02db3a9c", + "status": "published" + } + ], + } + if keys_with_restriction and key in keys_with_restriction: + course.update({ + "location_restriction": { + "restriction_type": "blocklist", + "countries": ["CN"], + "states": [] + } + }) + + courses.append(course) + + return courses + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys") + @mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_successful_response( + self, + country_code_from_ip_mock, + get_amplitude_course_recommendations_mock, + get_course_data_view_mock, + get_course_data_util_mock, + get_user_enrolled_course_keys_mock, + ): + """ + Verify 2 cross product course recommendations are returned + and 4 amplitude courses are returned + """ + country_code_from_ip_mock.return_value = "za" + get_user_enrolled_course_keys_mock.return_value = [] + get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys] + + mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys) + mock_amplitude_course_data = self._get_product_recommendations(self.amplitude_keys) + get_course_data_view_mock.side_effect = mock_cross_product_course_data + get_course_data_util_mock.side_effect = mock_amplitude_course_data + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + cross_product_course_data = response_content["crossProductCourses"] + amplitude_course_data = response_content["amplitudeCourses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(cross_product_course_data), 2) + self.assertEqual(len(amplitude_course_data), 4) + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys") + @mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_successful_course_filtering( + self, + country_code_from_ip_mock, + get_amplitude_course_recommendations_mock, + get_course_data_view_mock, + get_course_data_util_mock, + get_user_enrolled_course_keys_mock, + ): + """ + Verify 1 cross product course recommendation is returned + and 2 amplitude courses are returned with filtering done for + enrolled courses and courses with country restrictions + """ + country_code_from_ip_mock.return_value = "cn" + get_user_enrolled_course_keys_mock.return_value = self.enrolled_course_run_keys + get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys] + + mock_cross_product_course_data = self._get_product_recommendations( + self.associated_course_keys, self.cross_product_location_restriction_keys + ) + mock_amplitude_course_data = self._get_product_recommendations( + self.amplitude_keys, self.amplitude_location_restriction_keys + ) + get_course_data_view_mock.side_effect = mock_cross_product_course_data + get_course_data_util_mock.side_effect = mock_amplitude_course_data + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + cross_product_course_data = response_content["crossProductCourses"] + amplitude_course_data = response_content["amplitudeCourses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(cross_product_course_data), 1) + self.assertEqual(len(amplitude_course_data), 2) + for course in amplitude_course_data: + course_key = course["title"][2] + assert course_key not in [*self.amplitude_location_restriction_keys, *self.enrolled_course_keys] + for course in cross_product_course_data: + course_key = course["title"][2] + assert course_key not in self.cross_product_location_restriction_keys + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", get_general_recommendations()) + @mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys") + @mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_fallback_recommendations_when_enrolled_courses_removed( + self, + country_code_from_ip_mock, + get_amplitude_course_recommendations_mock, + get_course_data_view_mock, + get_course_data_util_mock, + get_user_enrolled_course_keys_mock + ): + """ + Verify 2 cross product course recommendations are returned + and 4 fallback amplitude recommendations are returned if no courses are left + after filtering due to courses being already enrolled in + """ + + country_code_from_ip_mock.return_value = "za" + get_user_enrolled_course_keys_mock.return_value = self.amplitude_course_run_keys + get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys] + + mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys) + mock_amplitude_course_data = self._get_product_recommendations(self.amplitude_keys) + get_course_data_view_mock.side_effect = mock_cross_product_course_data + get_course_data_util_mock.side_effect = mock_amplitude_course_data + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + cross_product_course_data = response_content["crossProductCourses"] + amplitude_course_data = response_content["amplitudeCourses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(cross_product_course_data), 2) + self.assertEqual(len(amplitude_course_data), 4) + for course in amplitude_course_data: + self.assertEqual(course["title"], "Introduction to Computer Science and Programming Using Python") + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", get_general_recommendations()) + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_fallback_recommendations_when_error_querying_amplitude( + self, + country_code_from_ip_mock, + get_amplitude_course_recommendations_mock, + get_course_data_mock, + ): + """ + Verify 2 cross product course recommendations are returned + and 4 fallback amplitude recommendations are returned + if there was an error querying amplitude for recommendations + """ + + country_code_from_ip_mock.return_value = "za" + get_amplitude_course_recommendations_mock.side_effect = Exception() + + mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys) + get_course_data_mock.side_effect = mock_cross_product_course_data + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + cross_product_course_data = response_content["crossProductCourses"] + amplitude_course_data = response_content["amplitudeCourses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(cross_product_course_data), 2) + self.assertEqual(len(amplitude_course_data), 4) + for course in amplitude_course_data: + self.assertEqual(course["title"], "Introduction to Computer Science and Programming Using Python") + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", get_general_recommendations()) + @mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_fallback_recommendations_when_no_amplitude_recommended_keys( + self, + country_code_from_ip_mock, + get_amplitude_course_recommendations_mock, + get_course_data_mock, + ): + """ + Verify 2 cross product course recommendations are returned + and 4 fallback amplitude recommendations are returned + if amplitude gave back no course keys + """ + + country_code_from_ip_mock.return_value = "za" + get_amplitude_course_recommendations_mock.side_effect = [False, True, []] + + mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys) + get_course_data_mock.side_effect = mock_cross_product_course_data + + response = self.client.get(self._get_url('edx+HL0')) + response_content = json.loads(response.content) + cross_product_course_data = response_content["crossProductCourses"] + amplitude_course_data = response_content["amplitudeCourses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(cross_product_course_data), 2) + self.assertEqual(len(amplitude_course_data), 4) + for course in amplitude_course_data: + self.assertEqual(course["title"], "Introduction to Computer Science and Programming Using Python") + + @mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys) + @mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys") + @mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data") + @mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations") + @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") + def test_response_with_amplitude_and_no_cross_product_courses( + self, + country_code_from_ip_mock, + get_amplitude_course_recommendations_mock, + get_course_data_mock, + get_user_enrolled_course_keys_mock + ): + """ + Verify that if no cross product courses are returned, + then 4 fallback amplitude recommendations will still be returned + """ + + country_code_from_ip_mock.return_value = "za" + get_user_enrolled_course_keys_mock.return_value = self.enrolled_course_run_keys + get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys] + + mock_amplitude_course_data = self._get_product_recommendations(self.amplitude_keys) + get_course_data_mock.side_effect = mock_amplitude_course_data + + response = self.client.get(self._get_url('No+Association')) + response_content = json.loads(response.content) + cross_product_course_data = response_content["crossProductCourses"] + amplitude_course_data = response_content["amplitudeCourses"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(cross_product_course_data), 0) + self.assertEqual(len(amplitude_course_data), 4) + + @ddt.ddt class TestDashboardRecommendationsApiView(TestRecommendationsBase): """Unit tests for the course recommendations on learner home page.""" diff --git a/lms/djangoapps/learner_recommendations/urls.py b/lms/djangoapps/learner_recommendations/urls.py index 5b21c38662..bd351614e7 100644 --- a/lms/djangoapps/learner_recommendations/urls.py +++ b/lms/djangoapps/learner_recommendations/urls.py @@ -16,7 +16,10 @@ urlpatterns = [ re_path(fr'^cross_product/{settings.COURSE_ID_PATTERN}/$', views.CrossProductRecommendationsView.as_view(), name='cross_product_recommendations'), + re_path(fr'^product_recommendations/{settings.COURSE_ID_PATTERN}/$', + views.ProductRecommendationsView.as_view(), + name='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 1ed6471fd5..dee5e1c3ac 100644 --- a/lms/djangoapps/learner_recommendations/utils.py +++ b/lms/djangoapps/learner_recommendations/utils.py @@ -150,6 +150,7 @@ def filter_recommended_courses( recommendation_count=10, user_country_code=None, request_course_key=None, + course_fields=None, ): """ Returns the filtered course recommendations. The unfiltered course keys @@ -164,6 +165,7 @@ def filter_recommended_courses( recommendation_count: the maximum count of recommendations to be returned user_country_code: if provided, will apply location restrictions to recommendations request_course_key: if provided, will filter out that course from recommendations (used for course about page) + fields: if provided, collects those fields on each course being queried, otherwise collects default fields Returns: filtered_recommended_courses (list): A list of filtered course objects. @@ -180,7 +182,7 @@ def filter_recommended_courses( "location_restriction", "marketing_url", "programs", - ] + ] if not course_fields else course_fields # Filter out enrolled courses . course_keys_to_filter_out = _get_user_enrolled_course_keys(user) diff --git a/lms/djangoapps/learner_recommendations/views.py b/lms/djangoapps/learner_recommendations/views.py index d4787e2e06..953518018c 100644 --- a/lms/djangoapps/learner_recommendations/views.py +++ b/lms/djangoapps/learner_recommendations/views.py @@ -19,24 +19,26 @@ from rest_framework.views import APIView from common.djangoapps.track import segment from common.djangoapps.student.toggles import show_fallback_recommendations from openedx.core.djangoapps.geoinfo.api import country_code_from_ip +from openedx.core.djangoapps.catalog.utils import get_course_data from openedx.features.enterprise_support.utils import is_enterprise_learner + from lms.djangoapps.learner_recommendations.toggles import ( enable_course_about_page_recommendations, enable_dashboard_recommendations, ) from lms.djangoapps.learner_recommendations.utils import ( + _has_country_restrictions, get_amplitude_course_recommendations, filter_recommended_courses, is_user_enrolled_in_ut_austin_masters_program, - _has_country_restrictions, get_cross_product_recommendations, - get_active_course_run + get_active_course_run, ) -from lms.djangoapps.learner_recommendations.serializers import CrossProductRecommendationsSerializer -from openedx.core.djangoapps.catalog.utils import get_course_data from lms.djangoapps.learner_recommendations.serializers import ( AboutPageRecommendationsSerializer, DashboardRecommendationsSerializer, + CrossProductAndAmplitudeRecommendationsSerializer, + CrossProductRecommendationsSerializer, ) log = logging.getLogger(__name__) @@ -192,6 +194,101 @@ class CrossProductRecommendationsView(APIView): ) +class ProductRecommendationsView(APIView): + """ + **Example Request** + + GET api/learner_recommendations/product_recommendations/{course_id}/ + """ + + authentication_classes = ( + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated, NotJwtRestrictedApplication) + + fields = [ + "title", + "owners", + "image", + "url_slug", + "course_type", + "course_runs", + "location_restriction", + ] + + def _get_amplitude_recommendations(self, user, user_country_code): + """ + Helper for getting amplitude recommendations + """ + + fallback_recommendations = settings.GENERAL_RECOMMENDATIONS[0:4] + + try: + _, _, course_keys = get_amplitude_course_recommendations( + user.id, settings.DASHBOARD_AMPLITUDE_RECOMMENDATION_ID + ) + except Exception as ex: # pylint: disable=broad-except + log.warning(f"Cannot get recommendations from Amplitude: {ex}") + return fallback_recommendations + + if not course_keys: + return fallback_recommendations + + filtered_courses = filter_recommended_courses( + user, course_keys, recommendation_count=4, user_country_code=user_country_code, course_fields=self.fields + ) + + return filtered_courses if len(filtered_courses) > 0 else fallback_recommendations + + def _get_cross_product_recommendations(self, course_key, user_country_code): + """ + Helper for getting cross product recommendations + """ + + associated_course_keys = get_cross_product_recommendations(course_key) + + if not associated_course_keys: + return [] + + course_data = [get_course_data(key, self.fields) for key in associated_course_keys] + filtered_cross_product_courses = [] + + for course in course_data: + if ( + course + and course.get("course_runs", []) + and not _has_country_restrictions(course, user_country_code) + ): + + filtered_cross_product_courses.append(course) + + return filtered_cross_product_courses + + def get(self, request, course_id): + """ + Returns cross product and amplitude recommendation courses + """ + + ip_address = get_client_ip(request)[0] + user_country_code = country_code_from_ip(ip_address).upper() + course_locator = CourseKey.from_string(course_id) + course_key = f'{course_locator.org}+{course_locator.course}' + + amplitude_recommendations = self._get_amplitude_recommendations(request.user, user_country_code) + cross_product_recommendations = self._get_cross_product_recommendations(course_key, user_country_code) + + return Response( + CrossProductAndAmplitudeRecommendationsSerializer( + { + "crossProductCourses": cross_product_recommendations, + "amplitudeCourses": amplitude_recommendations + } + ).data, + status=200 + ) + + class DashboardRecommendationsApiView(APIView): """ API to get personalized recommendations from Amplitude.