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 aaaafb89a2..3d072ca2fc 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -5,11 +5,8 @@ Unit tests for Learner Dashboard REST APIs and Views 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 ( @@ -20,7 +17,6 @@ from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, UserFactory, ) -from common.djangoapps.student.toggles import ENABLE_FALLBACK_RECOMMENDATIONS from lms.djangoapps.program_enrollments.rest_api.v1.tests.test_views import ( ProgramCacheMixin, ) @@ -243,216 +239,3 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): 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 f2f9789efd..93035c817d 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/urls.py +++ b/lms/djangoapps/learner_dashboard/api/v0/urls.py @@ -6,15 +6,13 @@ from django.urls import re_path from lms.djangoapps.learner_dashboard.api.v0.views import ( Programs, - ProgramProgressDetailView, - CourseRecommendationApiView + ProgramProgressDetailView ) 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 22e7a885f0..92dac75806 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -1,12 +1,7 @@ """ 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 @@ -14,9 +9,6 @@ from rest_framework.response import Response 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, @@ -24,11 +16,6 @@ from openedx.core.djangoapps.programs.utils import ( 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__) @@ -356,104 +343,3 @@ class ProgramProgressDetailView(APIView): '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 deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/learner_home/recommendations/serializers.py b/lms/djangoapps/learner_home/recommendations/serializers.py deleted file mode 100644 index 1b13da552d..0000000000 --- a/lms/djangoapps/learner_home/recommendations/serializers.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -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 deleted file mode 100644 index 17a2e31cc8..0000000000 --- a/lms/djangoapps/learner_home/recommendations/test_serializers.py +++ /dev/null @@ -1,86 +0,0 @@ -"""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 deleted file mode 100644 index 9edf9002ee..0000000000 --- a/lms/djangoapps/learner_home/recommendations/test_views.py +++ /dev/null @@ -1,326 +0,0 @@ -""" -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 deleted file mode 100644 index ec6b454db0..0000000000 --- a/lms/djangoapps/learner_home/recommendations/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -"""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 deleted file mode 100644 index adf730a1b8..0000000000 --- a/lms/djangoapps/learner_home/recommendations/views.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -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 deleted file mode 100644 index 29d0a3739a..0000000000 --- a/lms/djangoapps/learner_home/recommendations/waffle.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -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 eee0bf206d..b798630b32 100644 --- a/lms/djangoapps/learner_home/urls.py +++ b/lms/djangoapps/learner_home/urls.py @@ -12,7 +12,4 @@ app_name = "learner_home" 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 6ebeb93c51..97789ad71f 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_views.py +++ b/lms/djangoapps/learner_recommendations/tests/test_views.py @@ -120,7 +120,7 @@ class TestAboutPageRecommendationsView(TestRecommendationsBase): self.assertEqual(response.status_code, 404) self.assertEqual(response.data, None) - @mock.patch("lms.djangoapps.learner_dashboard.api.v0.views.segment.track") + @mock.patch("lms.djangoapps.learner_recommendations.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 29f883b1b6..2d556eabba 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/dashboard/v0/recommendation/courses/`) + const coursesRecommendationData = await fetch(`${this.props.lmsRootUrl}/api/learner_recommendations/courses/`) .then(response => response.json()) .catch(() => ({ courses: this.props.generalRecommendations,