From 3b991e66209c47063030815a723865ee2cde26f1 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Tue, 11 Apr 2023 11:17:29 +0500 Subject: [PATCH] Revert "refactor: recommendations code refactoring (#31990)" (#32047) This reverts commit 20b1e65c214cba9ca83e3a605b81127bdc9b6fe9. --- .../api/v0/tests/test_views.py | 217 ++++++++++++ .../learner_dashboard/api/v0/urls.py | 4 +- .../learner_dashboard/api/v0/views.py | 114 ++++++ .../learner_home/recommendations/__init__.py | 0 .../recommendations/serializers.py | 25 ++ .../recommendations/test_serializers.py | 86 +++++ .../recommendations/test_views.py | 326 ++++++++++++++++++ .../learner_home/recommendations/urls.py | 13 + .../learner_home/recommendations/views.py | 129 +++++++ .../learner_home/recommendations/waffle.py | 26 ++ lms/djangoapps/learner_home/urls.py | 3 + .../tests/test_views.py | 2 +- .../RecommendationsPanel.jsx | 2 +- 13 files changed, 944 insertions(+), 3 deletions(-) create mode 100644 lms/djangoapps/learner_home/recommendations/__init__.py create mode 100644 lms/djangoapps/learner_home/recommendations/serializers.py create mode 100644 lms/djangoapps/learner_home/recommendations/test_serializers.py create mode 100644 lms/djangoapps/learner_home/recommendations/test_views.py create mode 100644 lms/djangoapps/learner_home/recommendations/urls.py create mode 100644 lms/djangoapps/learner_home/recommendations/views.py create mode 100644 lms/djangoapps/learner_home/recommendations/waffle.py diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py index 3d072ca2fcf5..aaaafb89a2d8 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -5,8 +5,11 @@ from unittest import mock from uuid import uuid4 +import ddt from django.core.cache import cache +from django.test import TestCase from django.urls import reverse_lazy +from edx_toggles.toggles.testutils import override_waffle_flag from enterprise.models import EnterpriseCourseEnrollment from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import ( @@ -17,6 +20,7 @@ CourseEnrollmentFactory, UserFactory, ) +from common.djangoapps.student.toggles import ENABLE_FALLBACK_RECOMMENDATIONS from lms.djangoapps.program_enrollments.rest_api.v1.tests.test_views import ( ProgramCacheMixin, ) @@ -239,3 +243,216 @@ def test_program_empty_list_if_no_enterprise_enrollments(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, []) + + +@ddt.ddt +class TestCourseRecommendationApiView(TestCase): + """Unit tests for the course recommendations on dashboard page.""" + + url = reverse_lazy("learner_dashboard:v0:courses") + GENERAL_RECOMMENDATIONS = [ + { + "course_key": "HogwartsX+6.00.1x", + "logo_image_url": "https://discovery/organization/logos/logo1.png", + "marketing_url": "https://marketing-site.com/course/hogwarts-101", + "title": "Defense Against the Dark Arts", + }, + { + "course_key": "MonstersX+SC101EN", + "logo_image_url": "https://discovery/organization/logos/logo2.png", + "marketing_url": "https://marketing-site.com/course/monsters-anatomy-101", + "title": "Scaring 101", + }, + ] + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.client.login(username=self.user.username, password="test") + self.recommended_courses = [ + "MITx+6.00.1x", + "IBM+PY0101EN", + "HarvardX+CS50P", + "UQx+IELTSx", + "HarvardX+CS50x", + "Harvard+CS50z", + "BabsonX+EPS03x", + "TUMx+QPLS2x", + "NYUx+FCS.NET.1", + "MichinX+101x", + ] + self.general_recommendation_courses = ["HogwartsX+6.00.1x", "MonstersX+SC101EN"] + + def _get_filtered_courses(self): + """ + Returns the filtered course data + """ + filtered_course = [] + for course_key in self.recommended_courses[:5]: + filtered_course.append({ + "key": course_key, + "title": f"Title for {course_key}", + "logo_image_url": "https://www.logo_image_url.com", + "marketing_url": "https://www.marketing_url.com", + }) + return filtered_course + + @ddt.data( + (True, GENERAL_RECOMMENDATIONS), + (False, []), + ) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch( + "lms.djangoapps.learner_dashboard.api.v0.views.get_amplitude_course_recommendations" + ) + @ddt.unpack + def test_amplitude_user_profile_call_failed( + self, + show_fallback_recommendations, + expected_course_list, + get_amplitude_course_recommendations_mock, + ): + """ + Test that if the call to Amplitude user profile API fails, we return the + fallback recommendations. + + If the fallback recommendations are not configured, an empty course list is returned. + """ + get_amplitude_course_recommendations_mock.side_effect = Exception + with override_waffle_flag( + ENABLE_FALLBACK_RECOMMENDATIONS, active=show_fallback_recommendations + ): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, {"courses": expected_course_list, "is_control": None} + ) + + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch("lms.djangoapps.learner_dashboard.api.v0.views.segment.track") + @mock.patch( + "lms.djangoapps.learner_dashboard.api.v0.views.get_amplitude_course_recommendations" + ) + def test_amplitude_recommended_no_courses( + self, + get_amplitude_course_recommendations_mock, + segment_mock, + ): + """ + Verify API returns fallback recommendations if no courses are recommended by Amplitude. + """ + get_amplitude_course_recommendations_mock.return_value = [False, True, []] + + with override_waffle_flag(ENABLE_FALLBACK_RECOMMENDATIONS, active=True): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"courses": self.GENERAL_RECOMMENDATIONS, "is_control": False}, + ) + + # Verify that the segment event was fired + assert segment_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed" + self.assertEqual( + segment_mock.call_args[0][2], + { + "is_control": False, + "amplitude_recommendations": False, + "course_key_array": self.general_recommendation_courses, + "page": "dashboard", + }, + ) + + @mock.patch("lms.djangoapps.learner_dashboard.api.v0.views.segment.track") + @mock.patch( + "lms.djangoapps.learner_dashboard.api.v0.views.get_amplitude_course_recommendations" + ) + @mock.patch("lms.djangoapps.learner_dashboard.api.v0.views.filter_recommended_courses") + def test_get_course_recommendations( + self, + filter_recommended_courses_mock, + get_amplitude_course_recommendations_mock, + segment_mock, + ): + """ + Verify API returns course recommendations for users that fall in non-control group. + """ + filter_recommended_courses_mock.return_value = self._get_filtered_courses() + get_amplitude_course_recommendations_mock.return_value = [ + False, + True, + self.recommended_courses, + ] + expected_recommendations = 5 + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.get("is_control"), False) + self.assertEqual(len(response.data.get("courses")), expected_recommendations) + + # Verify that the segment event was fired + assert segment_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed" + self.assertEqual( + segment_mock.call_args[0][2], + { + "is_control": False, + "amplitude_recommendations": True, + "course_key_array": [course.get("key") for course in + self._get_filtered_courses()[:expected_recommendations]], + "page": "dashboard", + }, + ) + + @ddt.data( + (True, False, None), + (False, True, False), + (False, False, None), + (True, True, True), + ) + @mock.patch("lms.djangoapps.learner_dashboard.api.v0.views.segment.track") + @mock.patch("lms.djangoapps.learner_dashboard.api.v0.views.filter_recommended_courses") + @mock.patch( + "lms.djangoapps.learner_dashboard.api.v0.views.get_amplitude_course_recommendations" + ) + @ddt.unpack + def test_recommendations_viewed_segment_event( + self, + is_control, + has_is_control, + expected_is_control, + get_amplitude_course_recommendations_mock, + filter_recommended_courses_mock, + segment_mock, + ): + filter_recommended_courses_mock.return_value = self._get_filtered_courses() + get_amplitude_course_recommendations_mock.return_value = [ + is_control, + has_is_control, + self.recommended_courses, + ] + self.client.get(self.url) + + assert segment_mock.call_count == 1 + assert segment_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed" + self.assertEqual( + segment_mock.call_args[0][2]["is_control"], expected_is_control + ) + + @mock.patch( + "lms.djangoapps.learner_dashboard.api.v0.views.is_user_enrolled_in_ut_austin_masters_program" + ) + def test_no_recommendations_for_masters_program_learners( + self, is_user_enrolled_in_ut_austin_masters_program_mock + ): + """ + Verify API returns no recommendations if a user is enrolled in UT Austin masters program. + """ + is_user_enrolled_in_ut_austin_masters_program_mock.return_value = True + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.get("is_control"), None) + self.assertEqual(len(response.data.get("courses")), 0) diff --git a/lms/djangoapps/learner_dashboard/api/v0/urls.py b/lms/djangoapps/learner_dashboard/api/v0/urls.py index 93035c817d3a..f2f9789efd62 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/urls.py +++ b/lms/djangoapps/learner_dashboard/api/v0/urls.py @@ -6,13 +6,15 @@ from lms.djangoapps.learner_dashboard.api.v0.views import ( Programs, - ProgramProgressDetailView + ProgramProgressDetailView, + CourseRecommendationApiView ) UUID_REGEX_PATTERN = r'[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}' app_name = 'v0' urlpatterns = [ + re_path(r'^recommendation/courses/$', CourseRecommendationApiView.as_view(), name='courses'), re_path( fr'^programs/(?P{UUID_REGEX_PATTERN})/$', Programs.as_view(), diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py index 92dac75806bf..22e7a885f09d 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -1,7 +1,12 @@ """ API v0 views. """ import logging +from django.conf import settings +from ipware.ip import get_client_ip from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import ( + SessionAuthenticationAllowInactiveUser, +) from enterprise.models import EnterpriseCourseEnrollment from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated @@ -9,6 +14,9 @@ from rest_framework.views import APIView from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.toggles import show_fallback_recommendations +from common.djangoapps.track import segment +from openedx.core.djangoapps.geoinfo.api import country_code_from_ip from openedx.core.djangoapps.programs.utils import ( ProgramProgressMeter, get_certificates, @@ -16,6 +24,11 @@ get_program_and_course_data, get_program_urls, ) +from lms.djangoapps.learner_recommendations.utils import ( + filter_recommended_courses, + get_amplitude_course_recommendations, + is_user_enrolled_in_ut_austin_masters_program, +) logger = logging.getLogger(__name__) @@ -343,3 +356,104 @@ def get(self, request, program_uuid): 'credit_pathways': credit_pathways, } ) + + +class CourseRecommendationApiView(APIView): + """ + **Example Request** + + GET api/dashboard/v0/recommendation/courses/ + """ + + authentication_classes = ( + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated,) + + def _emit_recommendations_viewed_event( + self, + user_id, + is_control, + recommended_courses, + amplitude_recommendations=True, + ): + """Emits an event to track student dashboard page visits.""" + segment.track( + user_id, + "edx.bi.user.recommendations.viewed", + { + "is_control": is_control, + "amplitude_recommendations": amplitude_recommendations, + "course_key_array": [ + course["course_key"] for course in recommended_courses + ], + "page": "dashboard", + }, + ) + + def _recommendations_response(self, user_id, is_control, recommendations, amplitude_recommendations): + """Helper method for general recommendations response""" + self._emit_recommendations_viewed_event( + user_id, is_control, recommendations, amplitude_recommendations + ) + return Response( + { + "courses": recommendations, + "is_control": is_control, + }, + status=200, + ) + + def _course_data(self, course): + """Helper method for personalized recommendation response""" + return { + "course_key": course.get("key"), + "title": course.get("title"), + "logo_image_url": course.get("owners")[0]["logo_image_url"] if course.get( + "owners") else "", + "marketing_url": course.get("marketing_url"), + } + + def get(self, request): + """Retrieves course recommendations details of a user in a specified course.""" + user_id = request.user.id + + if is_user_enrolled_in_ut_austin_masters_program(request.user): + return self._recommendations_response(user_id, None, [], False) + + fallback_recommendations = settings.GENERAL_RECOMMENDATIONS if show_fallback_recommendations() else [] + + try: + ( + is_control, + has_is_control, + course_keys, + ) = get_amplitude_course_recommendations(user_id, settings.DASHBOARD_AMPLITUDE_RECOMMENDATION_ID) + except Exception as ex: # pylint: disable=broad-except + logger.warning(f"Cannot get recommendations from Amplitude: {ex}") + return self._recommendations_response( + user_id, None, fallback_recommendations, False + ) + + is_control = is_control if has_is_control else None + + if is_control or is_control is None or not course_keys: + return self._recommendations_response( + user_id, is_control, fallback_recommendations, False + ) + + ip_address = get_client_ip(request)[0] + user_country_code = country_code_from_ip(ip_address).upper() + filtered_courses = filter_recommended_courses( + request.user, course_keys, user_country_code=user_country_code, recommendation_count=5 + ) + if not filtered_courses: + return self._recommendations_response( + user_id, is_control, fallback_recommendations, False + ) + + recommended_courses = list(map(self._course_data, filtered_courses)) + return self._recommendations_response( + user_id, is_control, recommended_courses, True + ) diff --git a/lms/djangoapps/learner_home/recommendations/__init__.py b/lms/djangoapps/learner_home/recommendations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/learner_home/recommendations/serializers.py b/lms/djangoapps/learner_home/recommendations/serializers.py new file mode 100644 index 000000000000..1b13da552d13 --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/serializers.py @@ -0,0 +1,25 @@ +""" +Serializers for Course Recommendations +""" +from rest_framework import serializers + + +class RecommendedCourseSerializer(serializers.Serializer): + """Serializer for a recommended course from the recommendation engine""" + + courseKey = serializers.CharField(source="course_key") + logoImageUrl = serializers.URLField(source="logo_image_url") + marketingUrl = serializers.URLField(source="marketing_url") + title = serializers.CharField() + + +class CourseRecommendationSerializer(serializers.Serializer): + """Recommended courses by the Amplitude""" + + courses = serializers.ListField( + child=RecommendedCourseSerializer(), allow_empty=True + ) + isControl = serializers.BooleanField( + source="is_control", + default=None + ) diff --git a/lms/djangoapps/learner_home/recommendations/test_serializers.py b/lms/djangoapps/learner_home/recommendations/test_serializers.py new file mode 100644 index 000000000000..17a2e31cc81c --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/test_serializers.py @@ -0,0 +1,86 @@ +"""Tests for serializers for the Learner Home""" + +from uuid import uuid4 + +from django.test import TestCase + +from lms.djangoapps.learner_home.recommendations.serializers import ( + CourseRecommendationSerializer, +) +from lms.djangoapps.learner_home.test_utils import ( + random_url, +) + + +class TestCourseRecommendationSerializer(TestCase): + """High-level tests for CourseRecommendationSerializer""" + + @classmethod + def mock_recommended_courses(cls, courses_count=2): + """Sample course data""" + + recommended_courses = [] + + for _ in range(courses_count): + recommended_courses.append( + { + "course_key": str(uuid4()), + "logo_image_url": random_url(), + "marketing_url": random_url(), + "title": str(uuid4()), + }, + ) + + return recommended_courses + + def test_no_recommended_courses(self): + """That that data serializes correctly for empty courses list""" + + recommended_courses = self.mock_recommended_courses(courses_count=0) + + output_data = CourseRecommendationSerializer( + { + "courses": recommended_courses, + } + ).data + + self.assertDictEqual( + output_data, + { + "courses": [], + "isControl": None, + }, + ) + + def test_happy_path(self): + """Test that data serializes correctly""" + + recommended_courses = self.mock_recommended_courses() + + output_data = CourseRecommendationSerializer( + { + "courses": recommended_courses, + "is_control": False, + } + ).data + + self.assertDictEqual( + output_data, + { + "courses": [ + { + "courseKey": recommended_courses[0]["course_key"], + "logoImageUrl": recommended_courses[0]["logo_image_url"], + "marketingUrl": recommended_courses[0]["marketing_url"], + "title": recommended_courses[0]["title"], + }, + { + "courseKey": recommended_courses[1]["course_key"], + "logoImageUrl": recommended_courses[1]["logo_image_url"], + "marketingUrl": recommended_courses[1]["marketing_url"], + "title": recommended_courses[1]["title"], + }, + ], + "isControl": False, + }, + ) diff --git a/lms/djangoapps/learner_home/recommendations/test_views.py b/lms/djangoapps/learner_home/recommendations/test_views.py new file mode 100644 index 000000000000..9edf9002eefe --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/test_views.py @@ -0,0 +1,326 @@ +""" +Tests for Course Recommendations +""" + +import json +from unittest import mock +from unittest.mock import Mock + +import ddt +from django.test import TestCase +from django.urls import reverse_lazy +from edx_toggles.toggles.testutils import override_waffle_flag + +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.toggles import ENABLE_FALLBACK_RECOMMENDATIONS +from lms.djangoapps.learner_home.test_utils import ( + random_url, +) +from lms.djangoapps.learner_home.recommendations.waffle import ( + ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, +) + + +@ddt.ddt +class TestCourseRecommendationApiView(TestCase): + """Unit tests for the course recommendations on learner home page.""" + + url = reverse_lazy("learner_home:courses") + + GENERAL_RECOMMENDATIONS = [ + { + "course_key": "HogwartsX+6.00.1x", + "logo_image_url": random_url(), + "marketing_url": random_url(), + "title": "Defense Against the Dark Arts", + }, + { + "course_key": "MonstersX+SC101EN", + "logo_image_url": random_url(), + "marketing_url": random_url(), + "title": "Scaring 101", + }, + ] + + SERIALIZED_GENERAL_RECOMMENDATIONS = [ + { + "courseKey": GENERAL_RECOMMENDATIONS[0]["course_key"], + "logoImageUrl": GENERAL_RECOMMENDATIONS[0]["logo_image_url"], + "marketingUrl": GENERAL_RECOMMENDATIONS[0]["marketing_url"], + "title": GENERAL_RECOMMENDATIONS[0]["title"], + }, + { + "courseKey": GENERAL_RECOMMENDATIONS[1]["course_key"], + "logoImageUrl": GENERAL_RECOMMENDATIONS[1]["logo_image_url"], + "marketingUrl": GENERAL_RECOMMENDATIONS[1]["marketing_url"], + "title": GENERAL_RECOMMENDATIONS[1]["title"], + }, + ] + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.client.login(username=self.user.username, password="test") + self.recommended_courses = [ + "MITx+6.00.1x", + "IBM+PY0101EN", + "HarvardX+CS50P", + "UQx+IELTSx", + "HarvardX+CS50x", + "Harvard+CS50z", + "BabsonX+EPS03x", + "TUMx+QPLS2x", + "NYUx+FCS.NET.1", + "MichinX+101x", + ] + self.course_run_keys = [ + "course-v1:MITx+6.00.1x+Run_0", + "course-v1:IBM+PY0101EN+Run_0", + "course-v1:HarvardX+CS50P+Run_0", + "course-v1:UQx+IELTSx+Run_0", + "course-v1:HarvardX+CS50x+Run_0", + "course-v1:Harvard+CS50z+Run_0", + "course-v1:BabsonX+EPS03x+Run_0", + "course-v1:TUMx+QPLS2x+Run_0", + "course-v1:NYUx+FCS.NET.1+Run_0", + "course-v1:MichinX+101x+Run_0", + ] + + def _get_filtered_courses(self): + """ + Returns the filtered course data + """ + filtered_course = [] + for course_key in self.recommended_courses[:5]: + filtered_course.append({ + "key": course_key, + "title": f"Title for {course_key}", + "logo_image_url": "https://www.logo_image_url.com", + "marketing_url": "https://www.marketing_url.com", + }) + + return filtered_course + + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=False) + def test_waffle_flag_off(self): + """ + Verify API returns 404 if waffle flag is off. + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data, None) + + @override_waffle_flag(ENABLE_FALLBACK_RECOMMENDATIONS, active=True) + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_amplitude_course_recommendations" + ) + def test_no_recommendations_from_amplitude( + self, get_amplitude_course_recommendations_mock + ): + """ + Verify API returns general recommendations if no course recommendations from amplitude. + """ + get_amplitude_course_recommendations_mock.return_value = [False, True, []] + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), False) + self.assertEqual( + response_content.get("courses"), + self.SERIALIZED_GENERAL_RECOMMENDATIONS, + ) + + @override_waffle_flag(ENABLE_FALLBACK_RECOMMENDATIONS, active=True) + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_amplitude_course_recommendations", + Mock(side_effect=Exception), + ) + def test_amplitude_api_unexpected_error(self): + """ + 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) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), None) + self.assertEqual( + response_content.get("courses"), + self.SERIALIZED_GENERAL_RECOMMENDATIONS, + ) + + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_amplitude_course_recommendations" + ) + @mock.patch("lms.djangoapps.learner_home.recommendations.views.filter_recommended_courses") + def test_get_course_recommendations( + self, filter_recommended_courses_mock, get_amplitude_course_recommendations_mock + ): + """ + Verify API returns course recommendations. + """ + get_amplitude_course_recommendations_mock.return_value = [ + False, + True, + self.recommended_courses, + ] + + filter_recommended_courses_mock.return_value = self._get_filtered_courses() + expected_recommendations_length = 5 + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), False) + self.assertEqual( + len(response_content.get("courses")), expected_recommendations_length + ) + + @override_waffle_flag(ENABLE_FALLBACK_RECOMMENDATIONS, active=True) + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_amplitude_course_recommendations" + ) + def test_general_recommendations( + self, get_amplitude_course_recommendations_mock + ): + """ + Test that a user gets general recommendations for the control group. + """ + get_amplitude_course_recommendations_mock.return_value = [ + True, + True, + self.recommended_courses, + ] + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), True) + self.assertEqual( + response_content.get("courses"), + self.SERIALIZED_GENERAL_RECOMMENDATIONS, + ) + + @override_waffle_flag(ENABLE_FALLBACK_RECOMMENDATIONS, active=False) + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_amplitude_course_recommendations" + ) + def test_fallback_recommendations_disabled( + self, get_amplitude_course_recommendations_mock + ): + """ + Test that a user gets no recommendations for the control group. + """ + get_amplitude_course_recommendations_mock.return_value = [ + True, + True, + [], + ] + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), True) + self.assertEqual(response_content.get("courses"), []) + + @override_waffle_flag(ENABLE_FALLBACK_RECOMMENDATIONS, active=True) + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_amplitude_course_recommendations" + ) + @mock.patch("lms.djangoapps.learner_home.recommendations.views.filter_recommended_courses") + def test_no_recommended_courses_after_filtration( + self, filter_recommended_courses_mock, get_amplitude_course_recommendations_mock + ): + """ + Test that if after filtering already enrolled courses from Amplitude recommendations + we are left with zero personalized recommendations, we return general recommendations. + """ + filter_recommended_courses_mock.return_value = [] + get_amplitude_course_recommendations_mock.return_value = [ + False, + True, + self.recommended_courses, + ] + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), False) + self.assertEqual( + response_content.get("courses"), + self.SERIALIZED_GENERAL_RECOMMENDATIONS, + ) + + @ddt.data( + (True, False, None), + (False, True, False), + (False, False, None), + (True, True, True), + ) + @mock.patch("lms.djangoapps.learner_home.recommendations.views.segment.track") + @mock.patch("lms.djangoapps.learner_home.recommendations.views.filter_recommended_courses") + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_amplitude_course_recommendations" + ) + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @ddt.unpack + def test_recommendations_viewed_segment_event( + self, + is_control, + has_is_control, + expected_is_control, + get_amplitude_course_recommendations_mock, + filter_recommended_courses_mock, + segment_track_mock + ): + """ + Test that Segment event is emitted with desired properties. + """ + get_amplitude_course_recommendations_mock.return_value = [ + is_control, + has_is_control, + self.recommended_courses, + ] + filter_recommended_courses_mock.return_value = self._get_filtered_courses() + self.client.get(self.url) + + assert segment_track_mock.call_count == 1 + assert segment_track_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed" + self.assertEqual(segment_track_mock.call_args[0][2]["is_control"], expected_is_control) + + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.is_user_enrolled_in_ut_austin_masters_program" + ) + def test_no_recommendations_for_masters_program_learners( + self, is_user_enrolled_in_ut_austin_masters_program_mock + ): + """ + Verify API returns no recommendations if a user is enrolled in UT Austin masters program. + """ + is_user_enrolled_in_ut_austin_masters_program_mock.return_value = True + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), None) + self.assertEqual(response_content.get("courses"), []) diff --git a/lms/djangoapps/learner_home/recommendations/urls.py b/lms/djangoapps/learner_home/recommendations/urls.py new file mode 100644 index 000000000000..ec6b454db0e8 --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/urls.py @@ -0,0 +1,13 @@ +"""Learner home URL routing configuration""" + +from django.urls import re_path + +from lms.djangoapps.learner_home.recommendations import views + +urlpatterns = [ + re_path( + r"^courses/$", + views.CourseRecommendationApiView.as_view(), + name="courses", + ), +] diff --git a/lms/djangoapps/learner_home/recommendations/views.py b/lms/djangoapps/learner_home/recommendations/views.py new file mode 100644 index 000000000000..adf730a1b861 --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/views.py @@ -0,0 +1,129 @@ +""" +Views for Course Recommendations in Learner Home +""" +import logging + +from django.conf import settings +from ipware.ip import get_client_ip +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import ( + SessionAuthenticationAllowInactiveUser, +) +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.djangoapps.student.toggles import show_fallback_recommendations +from common.djangoapps.track import segment +from openedx.core.djangoapps.geoinfo.api import country_code_from_ip +from lms.djangoapps.learner_home.recommendations.serializers import ( + CourseRecommendationSerializer, +) +from lms.djangoapps.learner_home.recommendations.waffle import ( + should_show_learner_home_amplitude_recommendations, +) +from lms.djangoapps.learner_recommendations.utils import ( + filter_recommended_courses, + get_amplitude_course_recommendations, + is_user_enrolled_in_ut_austin_masters_program, +) + + +logger = logging.getLogger(__name__) + + +class CourseRecommendationApiView(APIView): + """ + API to get personalized recommendations from Amplitude. + + **Example Request** + + GET /api/learner_home/recommendation/courses/ + """ + + authentication_classes = ( + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated, NotJwtRestrictedApplication) + + def get(self, request): + """ + Retrieves course recommendations details. + """ + if not should_show_learner_home_amplitude_recommendations(): + return Response(status=404) + + user_id = request.user.id + + if is_user_enrolled_in_ut_austin_masters_program(request.user): + return self._recommendations_response(user_id, None, [], False) + + fallback_recommendations = settings.GENERAL_RECOMMENDATIONS if show_fallback_recommendations() else [] + + try: + is_control, has_is_control, course_keys = get_amplitude_course_recommendations( + user_id, settings.DASHBOARD_AMPLITUDE_RECOMMENDATION_ID + ) + except Exception as ex: # pylint: disable=broad-except + logger.warning(f"Cannot get recommendations from Amplitude: {ex}") + return self._recommendations_response(user_id, None, fallback_recommendations, False) + + is_control = is_control if has_is_control else None + if is_control or is_control is None or not course_keys: + return self._recommendations_response(user_id, is_control, fallback_recommendations, False) + + ip_address = get_client_ip(request)[0] + user_country_code = country_code_from_ip(ip_address).upper() + filtered_courses = filter_recommended_courses( + request.user, course_keys, user_country_code=user_country_code, recommendation_count=5 + ) + # If no courses are left after filtering already enrolled courses from + # the list of amplitude recommendations, show general recommendations + # to the user. + if not filtered_courses: + return self._recommendations_response(user_id, is_control, fallback_recommendations, False) + + recommended_courses = list(map(self._course_data, filtered_courses)) + return self._recommendations_response(user_id, is_control, recommended_courses, True) + + def _emit_recommendations_viewed_event( + self, user_id, is_control, recommended_courses, amplitude_recommendations=True + ): + """Emits an event to track Learner Home page visits.""" + segment.track( + user_id, + "edx.bi.user.recommendations.viewed", + { + "is_control": is_control, + "amplitude_recommendations": amplitude_recommendations, + "course_key_array": [course["course_key"] for course in recommended_courses], + "page": "dashboard", + }, + ) + + def _recommendations_response(self, user_id, is_control, recommended_courses, amplitude_recommendations): + """ Helper method for general recommendations response. """ + self._emit_recommendations_viewed_event( + user_id, is_control, recommended_courses, amplitude_recommendations + ) + return Response( + CourseRecommendationSerializer( + { + "courses": recommended_courses, + "is_control": is_control, + } + ).data, + status=200, + ) + + def _course_data(self, course): + """Helper method for personalized recommendation response""" + return { + "course_key": course.get("key"), + "title": course.get("title"), + "logo_image_url": course.get("owners")[0]["logo_image_url"] if course.get( + "owners") else "", + "marketing_url": course.get("marketing_url"), + } diff --git a/lms/djangoapps/learner_home/recommendations/waffle.py b/lms/djangoapps/learner_home/recommendations/waffle.py new file mode 100644 index 000000000000..29d0a3739add --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/waffle.py @@ -0,0 +1,26 @@ +""" +Configuration of recommendation feature for Learner Home. +""" + +from edx_toggles.toggles import WaffleFlag + +# Namespace for Learner Home MFE waffle flags. +WAFFLE_FLAG_NAMESPACE = "learner_home_mfe" + +# Waffle flag to enable to recommendation panel on learner home mfe +# .. toggle_name: learner_home_mfe.enable_learner_home_amplitude_recommendations +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable to recommendation panel on learner home mfe +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2022-10-28 +# .. toggle_target_removal_date: None +# .. toggle_warning: None +# .. toggle_tickets: VAN-1138 +ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS = WaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.enable_learner_home_amplitude_recommendations", __name__ +) + + +def should_show_learner_home_amplitude_recommendations(): + return ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS.is_enabled() diff --git a/lms/djangoapps/learner_home/urls.py b/lms/djangoapps/learner_home/urls.py index b798630b322b..eee0bf206dc6 100644 --- a/lms/djangoapps/learner_home/urls.py +++ b/lms/djangoapps/learner_home/urls.py @@ -12,4 +12,7 @@ urlpatterns = [ re_path(r"^init/?", views.InitializeView.as_view(), name="initialize"), re_path(r"^mock/", include("lms.djangoapps.learner_home.mock.urls")), + re_path( + r"^recommendation/", include("lms.djangoapps.learner_home.recommendations.urls") + ), ] diff --git a/lms/djangoapps/learner_recommendations/tests/test_views.py b/lms/djangoapps/learner_recommendations/tests/test_views.py index a5327ab2be42..d582eaf334a8 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_views.py +++ b/lms/djangoapps/learner_recommendations/tests/test_views.py @@ -120,7 +120,7 @@ def test_amplitude_api_unexpected_error(self): self.assertEqual(response.status_code, 404) self.assertEqual(response.data, None) - @mock.patch("lms.djangoapps.learner_recommendations.views.segment.track") + @mock.patch("lms.djangoapps.learner_dashboard.api.v0.views.segment.track") @mock.patch( "lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations" ) diff --git a/lms/static/js/learner_dashboard/RecommendationsPanel.jsx b/lms/static/js/learner_dashboard/RecommendationsPanel.jsx index 2d556eabba92..29f883b1b6da 100644 --- a/lms/static/js/learner_dashboard/RecommendationsPanel.jsx +++ b/lms/static/js/learner_dashboard/RecommendationsPanel.jsx @@ -26,7 +26,7 @@ class RecommendationsPanel extends React.Component { }; getCourseList = async () => { - const coursesRecommendationData = await fetch(`${this.props.lmsRootUrl}/api/learner_recommendations/courses/`) + const coursesRecommendationData = await fetch(`${this.props.lmsRootUrl}/api/dashboard/v0/recommendation/courses/`) .then(response => response.json()) .catch(() => ({ courses: this.props.generalRecommendations,