diff --git a/lms/djangoapps/learner_home/docs/002-core-versus-experimental-code.rst b/lms/djangoapps/learner_home/docs/002-core-versus-experimental-code.rst new file mode 100644 index 0000000000..07becac50b --- /dev/null +++ b/lms/djangoapps/learner_home/docs/002-core-versus-experimental-code.rst @@ -0,0 +1,34 @@ +Core Versus Experimental Code +-------------- + +Status +====== + +Approved + +Context +======= + +One of the goals of the new Learner Home is to provide entry points for experimentation of different features. For example, the frontend offers a separate sidebar widget for showing a user their recommended courses. + +In general, it is expected that these experiments will *NOT* modify or impact the core page functionality, showing a user their current enrollments / entitlements. + +Decisions +========= + +Any experiments, functionalities that are not necessarily tied to the core functionality of the page, should not modify the main `init` call that delivers user enrollment / entitlement data but instead exist as separate views / APIs, ideally in separate folders under `learner_home`. + +This is both to avoid code bloat and protect the core functionality of Learner Home against bugs, outages, or regressions from experimental add-ons. + +Consequences +============ + +By separating experimental / add-on code from the core page functionality, breaks or regressions introduced by experiments are expected to only impact containers / widgets which rely on that experimental code, leaving core functionality relatively stable and reliable. + +While this is expected to increase the number of files / folders to keep track of inside of the `learner_home` app, the separation of files by functionality is expected to decrease cognitive load by making files / views more single-purpose and targeted. + +Alternatives +============ + +1. Allow experimental code to live in the same files as core functionality. This is not so bad but increases the size and complexity of files and increases cognitive load while developing / debugging. +2. Allow experimental code to graft on to core functionality. We want to avoid this as it will necessarily slow the pace of experimentation and increase the need to regression test any experimental changes to avoid breaking core functionality. diff --git a/lms/djangoapps/learner_home/mock/__init__.py b/lms/djangoapps/learner_home/mock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_home/mock_data.json b/lms/djangoapps/learner_home/mock/mock_data.json similarity index 100% rename from lms/djangoapps/learner_home/mock_data.json rename to lms/djangoapps/learner_home/mock/mock_data.json diff --git a/lms/djangoapps/learner_home/mock_views.py b/lms/djangoapps/learner_home/mock/mock_views.py similarity index 97% rename from lms/djangoapps/learner_home/mock_views.py rename to lms/djangoapps/learner_home/mock/mock_views.py index e3a737f33b..266524115d 100644 --- a/lms/djangoapps/learner_home/mock_views.py +++ b/lms/djangoapps/learner_home/mock/mock_views.py @@ -10,7 +10,7 @@ from os import path from rest_framework.response import Response from rest_framework.generics import RetrieveAPIView -LEARNER_HOME_DIR = "/edx/app/edxapp/edx-platform/lms/djangoapps/learner_home" +LEARNER_HOME_DIR = "/edx/app/edxapp/edx-platform/lms/djangoapps/learner_home/mock" MOCK_DATA_FILE = "mock_data.json" diff --git a/lms/djangoapps/learner_home/mock/urls.py b/lms/djangoapps/learner_home/mock/urls.py new file mode 100644 index 0000000000..546872b29a --- /dev/null +++ b/lms/djangoapps/learner_home/mock/urls.py @@ -0,0 +1,9 @@ +"""Learner Home mock URL routing configuration""" + +from django.urls import re_path + +from lms.djangoapps.learner_home.mock import mock_views + +urlpatterns = [ + re_path(r"^init/?", mock_views.InitializeView.as_view(), name="mock_initialize"), +] diff --git a/lms/djangoapps/learner_home/recommendations/__init__.py b/lms/djangoapps/learner_home/recommendations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_home/recommendations/serializers.py b/lms/djangoapps/learner_home/recommendations/serializers.py new file mode 100644 index 0000000000..d2048e2ad2 --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/serializers.py @@ -0,0 +1,24 @@ +""" +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 + ) + isPersonalizedRecommendation = serializers.BooleanField( + source="is_personalized_recommendation" + ) 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 0000000000..8455d730ce --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/test_serializers.py @@ -0,0 +1,87 @@ +"""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, + "is_personalized_recommendation": False, + } + ).data + + self.assertDictEqual( + output_data, + { + "courses": [], + "isPersonalizedRecommendation": False, + }, + ) + + def test_happy_path(self): + """Test that data serializes correctly""" + + recommended_courses = self.mock_recommended_courses() + + output_data = CourseRecommendationSerializer( + { + "courses": recommended_courses, + "is_personalized_recommendation": True, + } + ).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"], + }, + ], + "isPersonalizedRecommendation": True, + }, + ) 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 0000000000..93d05c34d5 --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/test_views.py @@ -0,0 +1,263 @@ +""" +Tests for Course Recommendations +""" + +import json +from unittest import mock +from unittest.mock import Mock + +from django.urls import reverse_lazy +from edx_toggles.toggles.testutils import override_waffle_flag + +from common.djangoapps.student.tests.factories import ( + CourseEnrollmentFactory, + UserFactory, +) +from lms.djangoapps.learner_home.test_utils import ( + random_url, +) +from lms.djangoapps.learner_home.recommendations.waffle import ( + ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, +) +from xmodule.modulestore.tests.django_utils import ( + SharedModuleStoreTestCase, +) + + +class TestCourseRecommendationApiView(SharedModuleStoreTestCase): + """Unit tests for the course recommendations on learner home page.""" + + password = "test" + 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=self.password) + 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", + ] + self.course_data = { + "course_key": "MITx+6.00.1x", + "title": "Introduction to Computer Science and Programming Using Python", + "owners": [{"logo_image_url": "https://www.logo_image_url.com"}], + "marketing_url": "https://www.marketing_url.com", + } + + @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_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.get_personalized_course_recommendations" + ) + def test_no_recommendations_from_amplitude( + self, mocked_get_personalized_course_recommendations + ): + """ + Verify API returns general recommendations if no course recommendations from amplitude. + """ + mocked_get_personalized_course_recommendations.return_value = [False, []] + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isPersonalizedRecommendation"), False) + self.assertEqual( + response_content.get("courses"), + self.SERIALIZED_GENERAL_RECOMMENDATIONS, + ) + + @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_personalized_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("isPersonalizedRecommendation"), False) + 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_personalized_course_recommendations" + ) + @mock.patch("lms.djangoapps.learner_home.recommendations.views.get_course_data") + def test_get_course_recommendations( + self, mocked_get_course_data, mocked_get_personalized_course_recommendations + ): + """ + Verify API returns course recommendations. + """ + mocked_get_personalized_course_recommendations.return_value = [ + False, + self.recommended_courses, + ] + mocked_get_course_data.return_value = self.course_data + 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("isPersonalizedRecommendation"), True) + self.assertEqual( + len(response_content.get("courses")), expected_recommendations_length + ) + + @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_personalized_course_recommendations" + ) + def test_general_recommendations( + self, mocked_get_personalized_course_recommendations + ): + """ + Test that a user gets general recommendations for the control group. + """ + mocked_get_personalized_course_recommendations.return_value = [ + 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("isPersonalizedRecommendation"), False) + 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_personalized_course_recommendations" + ) + @mock.patch("lms.djangoapps.learner_home.recommendations.views.get_course_data") + def test_get_enrollable_course_recommendations( + self, mocked_get_course_data, mocked_get_personalized_course_recommendations + ): + """ + Verify API returns course recommendations for courses in which user is not enrolled. + """ + mocked_get_personalized_course_recommendations.return_value = [ + False, + self.recommended_courses, + ] + mocked_get_course_data.return_value = self.course_data + expected_recommendations = 4 + # enrolling in 6 courses + for course_run_key in self.course_run_keys[:6]: + CourseEnrollmentFactory(course_id=course_run_key, user=self.user) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isPersonalizedRecommendation"), True) + self.assertEqual(len(response_content.get("courses")), expected_recommendations) + + @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_personalized_course_recommendations" + ) + @mock.patch("lms.djangoapps.learner_home.recommendations.views.get_course_data") + def test_no_enrollable_course( + self, mocked_get_course_data, mocked_get_personalized_course_recommendations + ): + """ + Test that if after filtering already enrolled courses from Amplitude recommendations + we are left with zero personalized recommendations, we return general recommendations. + """ + mocked_get_personalized_course_recommendations.return_value = [ + False, + self.recommended_courses, + ] + mocked_get_course_data.return_value = self.course_data + + # Enrolling in all courses + for course_run_key in self.course_run_keys: + CourseEnrollmentFactory(course_id=course_run_key, user=self.user) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isPersonalizedRecommendation"), False) + self.assertEqual( + response_content.get("courses"), + self.SERIALIZED_GENERAL_RECOMMENDATIONS, + ) diff --git a/lms/djangoapps/learner_home/recommendations/urls.py b/lms/djangoapps/learner_home/recommendations/urls.py new file mode 100644 index 0000000000..ec6b454db0 --- /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/utils.py b/lms/djangoapps/learner_home/recommendations/utils.py new file mode 100644 index 0000000000..e6bc937f46 --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/utils.py @@ -0,0 +1,31 @@ +"""API utils""" + +import logging +import requests + +from django.conf import settings + +log = logging.getLogger(__name__) + + +def get_personalized_course_recommendations(user_id): + """Get personalize recommendations from Amplitude.""" + headers = { + "Authorization": f"Api-Key {settings.AMPLITUDE_API_KEY}", + "Content-Type": "application/json", + } + params = { + "user_id": user_id, + "get_recs": True, + "rec_id": settings.REC_ID, + } + response = requests.get(settings.AMPLITUDE_URL, params=params, headers=headers) + if response.status_code == 200: + response = response.json() + recommendations = response.get("userData", {}).get("recommendations", []) + if recommendations: + is_control = recommendations[0].get("is_control") + recommended_course_keys = recommendations[0].get("items") + return is_control, recommended_course_keys + + return True, [] diff --git a/lms/djangoapps/learner_home/recommendations/views.py b/lms/djangoapps/learner_home/recommendations/views.py new file mode 100644 index 0000000000..f2ad1420c8 --- /dev/null +++ b/lms/djangoapps/learner_home/recommendations/views.py @@ -0,0 +1,123 @@ +""" +Views for Course Recommendations in Learner Home +""" +import logging + +from django.conf import settings +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.models import CourseEnrollment +from common.djangoapps.track import segment +from lms.djangoapps.learner_home.recommendations.serializers import ( + CourseRecommendationSerializer, +) +from lms.djangoapps.learner_home.recommendations.utils import ( + get_personalized_course_recommendations, +) +from lms.djangoapps.learner_home.recommendations.waffle import ( + should_show_learner_home_amplitude_recommendations, +) +from openedx.core.djangoapps.catalog.utils import get_course_data + + +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) + + general_recommendations_response = Response( + CourseRecommendationSerializer( + { + "courses": settings.GENERAL_RECOMMENDATIONS, + "is_personalized_recommendation": False, + } + ).data, + status=200, + ) + + try: + user_id = request.user.id + is_control, course_keys = get_personalized_course_recommendations(user_id) + except Exception as ex: # pylint: disable=broad-except + logger.warning(f"Cannot get recommendations from Amplitude: {ex}") + return general_recommendations_response + + # Emits an event to track student dashboard page visits. + segment.track( + user_id, + "edx.bi.user.recommendations.viewed", + { + "is_personalized_recommendation": not is_control, + }, + ) + + if is_control or not course_keys: + return general_recommendations_response + + recommended_courses = [] + user_enrolled_course_keys = set() + fields = ["title", "owners", "marketing_url"] + + course_enrollments = CourseEnrollment.enrollments_for_user(request.user) + for course_enrollment in course_enrollments: + course_key = f"{course_enrollment.course_id.org}+{course_enrollment.course_id.course}" + user_enrolled_course_keys.add(course_key) + + # Pick 5 course keys, excluding the user's already enrolled course(s). + enrollable_course_keys = list( + set(course_keys).difference(user_enrolled_course_keys) + )[:5] + for course_id in enrollable_course_keys: + course_data = get_course_data(course_id, fields) + if course_data: + recommended_courses.append( + { + "course_key": course_id, + "title": course_data["title"], + "logo_image_url": course_data["owners"][0]["logo_image_url"], + "marketing_url": course_data.get("marketing_url"), + } + ) + + # If no courses are left after filtering already enrolled courses from + # the list of amplitude recommendations, show general recommendations + # to the user. + if not recommended_courses: + return general_recommendations_response + + return Response( + CourseRecommendationSerializer( + { + "courses": recommended_courses, + "is_personalized_recommendation": not is_control, + } + ).data, + status=200, + ) diff --git a/lms/djangoapps/learner_home/recommendations/waffle.py b/lms/djangoapps/learner_home/recommendations/waffle.py new file mode 100644 index 0000000000..29d0a3739a --- /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/serializers.py b/lms/djangoapps/learner_home/serializers.py index a426d591e0..3c83f433c7 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -1,6 +1,7 @@ """ -Serializers for the Learner Dashboard +Serializers for Learner Home """ + from datetime import date, timedelta from urllib.parse import urljoin @@ -538,26 +539,6 @@ class UnfulfilledEntitlementSerializer(serializers.Serializer): ).data -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 - ) - isPersonalizedRecommendation = serializers.BooleanField( - source="is_personalized_recommendation" - ) - - class SuggestedCourseSerializer(serializers.Serializer): """Serializer for a suggested course from recommendation engine""" diff --git a/lms/djangoapps/learner_home/test_serializers.py b/lms/djangoapps/learner_home/test_serializers.py index 172110e7ac..dcc55ecc1e 100644 --- a/lms/djangoapps/learner_home/test_serializers.py +++ b/lms/djangoapps/learner_home/test_serializers.py @@ -1,4 +1,6 @@ -"""Tests for serializers for the Learner Dashboard""" +""" +Tests for serializers for the Learner Home +""" from datetime import date, datetime, timedelta, timezone from itertools import product @@ -30,7 +32,6 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import ( from lms.djangoapps.learner_home.serializers import ( CertificateSerializer, CourseProviderSerializer, - CourseRecommendationSerializer, CourseRunSerializer, CourseSerializer, CreditSerializer, @@ -1030,81 +1031,6 @@ class TestUnfulfilledEntitlementSerializer(LearnerDashboardBaseTest): assert expected_keys == actual_keys -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, - "is_personalized_recommendation": False, - } - ).data - - self.assertDictEqual( - output_data, - { - "courses": [], - "isPersonalizedRecommendation": False, - }, - ) - - def test_happy_path(self): - """Test that data serializes correctly""" - - recommended_courses = self.mock_recommended_courses() - - output_data = CourseRecommendationSerializer( - { - "courses": recommended_courses, - "is_personalized_recommendation": True, - } - ).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"], - }, - ], - "isPersonalizedRecommendation": True, - }, - ) - - class TestSuggestedCourseSerializer(TestCase): """High-level tests for SuggestedCourseSerializer""" diff --git a/lms/djangoapps/learner_home/test_utils.py b/lms/djangoapps/learner_home/test_utils.py index 699248acfb..f8a7dc29f7 100644 --- a/lms/djangoapps/learner_home/test_utils.py +++ b/lms/djangoapps/learner_home/test_utils.py @@ -1,6 +1,7 @@ """ -Various utilities used for testing/test data. +Various utilities used for creating test data """ + import datetime from random import choice, getrandbits, randint from time import time diff --git a/lms/djangoapps/learner_home/test_views.py b/lms/djangoapps/learner_home/test_views.py index 79d127ab12..b95a53f9cb 100644 --- a/lms/djangoapps/learner_home/test_views.py +++ b/lms/djangoapps/learner_home/test_views.py @@ -1,18 +1,18 @@ -"""Test for learner views and related functions""" +""" +Test for Learner Home views and related functions +""" from contextlib import contextmanager import json -from unittest import mock from unittest.mock import Mock, patch from urllib.parse import urlencode from uuid import uuid4 import ddt from django.conf import settings -from django.urls import reverse, reverse_lazy +from django.urls import reverse from django.utils import timezone from django.test import TestCase, override_settings -from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from rest_framework.test import APITestCase @@ -42,9 +42,6 @@ from lms.djangoapps.learner_home.views import ( get_social_share_settings, get_course_share_urls, ) -from lms.djangoapps.learner_home.waffle import ( - ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, -) from openedx.core.djangoapps.catalog.tests.factories import ( CourseFactory as CatalogCourseFactory, CourseRunFactory as CatalogCourseRunFactory, @@ -863,242 +860,3 @@ class TestDashboardMasquerade(BaseTestDashboardView): # username has priority in the lookup assert response.status_code == 200 assert self.get_first_course_id(response) == str(user_3_enrollment.course_id) - - -class TestCourseRecommendationApiView(SharedModuleStoreTestCase): - """Unit tests for the course recommendations on learner home page.""" - - password = "test" - 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=self.password) - 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", - ] - self.course_data = { - "course_key": "MITx+6.00.1x", - "title": "Introduction to Computer Science and Programming Using Python", - "owners": [{"logo_image_url": "https://www.logo_image_url.com"}], - "marketing_url": "https://www.marketing_url.com", - } - - @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_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) - @mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS) - @mock.patch( - "lms.djangoapps.learner_home.views.get_personalized_course_recommendations" - ) - def test_no_recommendations_from_amplitude( - self, mocked_get_personalized_course_recommendations - ): - """ - Verify API returns general recommendations if no course recommendations from amplitude. - """ - mocked_get_personalized_course_recommendations.return_value = [False, []] - - response = self.client.get(self.url) - self.assertEqual(response.status_code, 200) - - response_content = json.loads(response.content) - self.assertEqual(response_content.get("isPersonalizedRecommendation"), False) - self.assertEqual( - response_content.get("courses"), - self.SERIALIZED_GENERAL_RECOMMENDATIONS, - ) - - @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.views.get_personalized_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("isPersonalizedRecommendation"), False) - 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.views.get_personalized_course_recommendations" - ) - @mock.patch("lms.djangoapps.learner_home.views.get_course_data") - def test_get_course_recommendations( - self, mocked_get_course_data, mocked_get_personalized_course_recommendations - ): - """ - Verify API returns course recommendations. - """ - mocked_get_personalized_course_recommendations.return_value = [ - False, - self.recommended_courses, - ] - mocked_get_course_data.return_value = self.course_data - 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("isPersonalizedRecommendation"), True) - self.assertEqual( - len(response_content.get("courses")), expected_recommendations_length - ) - - @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.views.get_personalized_course_recommendations" - ) - def test_general_recommendations( - self, mocked_get_personalized_course_recommendations - ): - """ - Test that a user gets general recommendations for the control group. - """ - mocked_get_personalized_course_recommendations.return_value = [ - 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("isPersonalizedRecommendation"), False) - 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.views.get_personalized_course_recommendations" - ) - @mock.patch("lms.djangoapps.learner_home.views.get_course_data") - def test_get_enrollable_course_recommendations( - self, mocked_get_course_data, mocked_get_personalized_course_recommendations - ): - """ - Verify API returns course recommendations for courses in which user is not enrolled. - """ - mocked_get_personalized_course_recommendations.return_value = [ - False, - self.recommended_courses, - ] - mocked_get_course_data.return_value = self.course_data - expected_recommendations = 4 - # enrolling in 6 courses - for course_run_key in self.course_run_keys[:6]: - CourseEnrollmentFactory(course_id=course_run_key, user=self.user) - - response = self.client.get(self.url) - self.assertEqual(response.status_code, 200) - - response_content = json.loads(response.content) - self.assertEqual(response_content.get("isPersonalizedRecommendation"), True) - self.assertEqual(len(response_content.get("courses")), expected_recommendations) - - @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.views.get_personalized_course_recommendations" - ) - @mock.patch("lms.djangoapps.learner_home.views.get_course_data") - def test_no_enrollable_course( - self, mocked_get_course_data, mocked_get_personalized_course_recommendations - ): - """ - Test that if after filtering already enrolled courses from Amplitude recommendations - we are left with zero personalized recommendations, we return general recommendations. - """ - mocked_get_personalized_course_recommendations.return_value = [ - False, - self.recommended_courses, - ] - mocked_get_course_data.return_value = self.course_data - - # Enrolling in all courses - for course_run_key in self.course_run_keys: - CourseEnrollmentFactory(course_id=course_run_key, user=self.user) - - response = self.client.get(self.url) - self.assertEqual(response.status_code, 200) - - response_content = json.loads(response.content) - self.assertEqual(response_content.get("isPersonalizedRecommendation"), False) - self.assertEqual( - response_content.get("courses"), - self.SERIALIZED_GENERAL_RECOMMENDATIONS, - ) diff --git a/lms/djangoapps/learner_home/urls.py b/lms/djangoapps/learner_home/urls.py index 56faf4fe2a..eee0bf206d 100644 --- a/lms/djangoapps/learner_home/urls.py +++ b/lms/djangoapps/learner_home/urls.py @@ -1,20 +1,18 @@ -"""Learner home URL routing configuration""" +""" +Learner Home URL routing configuration +""" -from django.urls import re_path +from django.urls import include, re_path -from lms.djangoapps.learner_home import mock_views, views +from lms.djangoapps.learner_home import views app_name = "learner_home" # Learner Dashboard Routing 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"^mock/init/?", mock_views.InitializeView.as_view(), name="mock_initialize" - ), - re_path( - r"^recommendation/courses/$", - views.CourseRecommendationApiView.as_view(), - name="courses", + r"^recommendation/", include("lms.djangoapps.learner_home.recommendations.urls") ), ] diff --git a/lms/djangoapps/learner_home/utils.py b/lms/djangoapps/learner_home/utils.py index e9af415c40..28e4479f94 100644 --- a/lms/djangoapps/learner_home/utils.py +++ b/lms/djangoapps/learner_home/utils.py @@ -1,10 +1,9 @@ -"""API utils""" +""" +Additional utilities for Learner Home +""" import logging -import requests -from time import time -from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import MultipleObjectsReturned from rest_framework.exceptions import PermissionDenied, NotFound @@ -17,31 +16,6 @@ log = logging.getLogger(__name__) User = get_user_model() -def exec_time_logged(func): - """Wrap the function and return result and execution time""" - - def wrap_func(*args, **kwargs): - # Time the function operation - t1 = time() - result = func(*args, **kwargs) - t2 = time() - - # Display lists / sets as their lengths instead of actual items - debug_args = [] - for arg in args: - if isinstance(arg, (list, set)): - debug_args.append(f"") - else: - debug_args.append(arg) - - # Log the output - log.info(f"{func.__name__!r} args:{debug_args} completed in {(t2-t1):.4f}s") - - return result - - return wrap_func - - def get_masquerade_user(request): """ Determine if the user is masquerading @@ -80,26 +54,3 @@ def get_masquerade_user(request): ) log.info(success_msg) return masquerade_user - - -def get_personalized_course_recommendations(user_id): - """Get personalize recommendations from Amplitude.""" - headers = { - "Authorization": f"Api-Key {settings.AMPLITUDE_API_KEY}", - "Content-Type": "application/json", - } - params = { - "user_id": user_id, - "get_recs": True, - "rec_id": settings.REC_ID, - } - response = requests.get(settings.AMPLITUDE_URL, params=params, headers=headers) - if response.status_code == 200: - response = response.json() - recommendations = response.get("userData", {}).get("recommendations", []) - if recommendations: - is_control = recommendations[0].get("is_control") - recommended_course_keys = recommendations[0].get("items") - return is_control, recommended_course_keys - - return True, [] diff --git a/lms/djangoapps/learner_home/views.py b/lms/djangoapps/learner_home/views.py index db7a3cfa4c..5ff9dc02d9 100644 --- a/lms/djangoapps/learner_home/views.py +++ b/lms/djangoapps/learner_home/views.py @@ -1,6 +1,7 @@ """ -Views for the learner dashboard. +Views for Learner Home """ + import logging from collections import OrderedDict @@ -26,7 +27,6 @@ from common.djangoapps.student.helpers import ( cert_info, user_has_passing_grade_in_course, ) -from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.views.dashboard import ( complete_course_mode_info, credit_statuses, @@ -34,7 +34,6 @@ from common.djangoapps.student.views.dashboard import ( get_filtered_course_entitlements, get_org_black_and_whitelist_for_site, ) -from common.djangoapps.track import segment from common.djangoapps.util.course import ( get_encoded_course_sharing_utm_params, get_link_for_about_page, @@ -48,17 +47,11 @@ from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user from lms.djangoapps.courseware.access_utils import check_course_open_for_learner from lms.djangoapps.learner_home.serializers import ( - CourseRecommendationSerializer, LearnerDashboardSerializer, ) from lms.djangoapps.learner_home.utils import ( get_masquerade_user, - get_personalized_course_recommendations, ) -from lms.djangoapps.learner_home.waffle import ( - should_show_learner_home_amplitude_recommendations, -) -from openedx.core.djangoapps.catalog.utils import get_course_data from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.programs.utils import ProgramProgressMeter from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -581,96 +574,3 @@ class InitializeView(APIView): # pylint: disable=unused-argument response_data = serialize_learner_home_data(learner_dash_data, context) return Response(response_data) - - -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) - - general_recommendations_response = Response( - CourseRecommendationSerializer( - { - "courses": settings.GENERAL_RECOMMENDATIONS, - "is_personalized_recommendation": False, - } - ).data, - status=200, - ) - - try: - user_id = request.user.id - is_control, course_keys = get_personalized_course_recommendations(user_id) - except Exception as ex: # pylint: disable=broad-except - logger.warning(f"Cannot get recommendations from Amplitude: {ex}") - return general_recommendations_response - - # Emits an event to track student dashboard page visits. - segment.track( - user_id, - "edx.bi.user.recommendations.viewed", - { - "is_personalized_recommendation": not is_control, - }, - ) - - if is_control or not course_keys: - return general_recommendations_response - - recommended_courses = [] - user_enrolled_course_keys = set() - fields = ["title", "owners", "marketing_url"] - - course_enrollments = CourseEnrollment.enrollments_for_user(request.user) - for course_enrollment in course_enrollments: - course_key = f"{course_enrollment.course_id.org}+{course_enrollment.course_id.course}" - user_enrolled_course_keys.add(course_key) - - # Pick 5 course keys, excluding the user's already enrolled course(s). - enrollable_course_keys = list( - set(course_keys).difference(user_enrolled_course_keys) - )[:5] - for course_id in enrollable_course_keys: - course_data = get_course_data(course_id, fields) - if course_data: - recommended_courses.append( - { - "course_key": course_id, - "title": course_data["title"], - "logo_image_url": course_data["owners"][0]["logo_image_url"], - "marketing_url": course_data.get("marketing_url"), - } - ) - - # If no courses are left after filtering already enrolled courses from - # the list of amplitude recommendations, show general recommendations - # to the user. - if not recommended_courses: - return general_recommendations_response - - return Response( - CourseRecommendationSerializer( - { - "courses": recommended_courses, - "is_personalized_recommendation": not is_control, - } - ).data, - status=200, - ) diff --git a/lms/djangoapps/learner_home/waffle.py b/lms/djangoapps/learner_home/waffle.py index 46459bcda8..2af62757a4 100644 --- a/lms/djangoapps/learner_home/waffle.py +++ b/lms/djangoapps/learner_home/waffle.py @@ -1,6 +1,5 @@ """ -This module contains various configuration settings via -waffle switches for the teams app. +Configuration for features of Learner Home """ from edx_toggles.toggles import WaffleFlag @@ -27,22 +26,3 @@ def should_redirect_to_learner_home_mfe(): return configuration_helpers.get_value( "ENABLE_LEARNER_HOME_MFE", ENABLE_LEARNER_HOME_MFE.is_enabled() ) - - -# 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()