From 52de7e9066d548fbfab5ea4831a18806cd0581d2 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Mon, 10 Oct 2022 12:09:28 +0500 Subject: [PATCH] feat: integrate amplitude API with learner_home frontend [VAN-1124] (#31098) --- .../api/v0/tests/test_views.py | 2 +- lms/djangoapps/learner_home/test_views.py | 95 ++++++++++++++++++- lms/djangoapps/learner_home/urls.py | 1 + lms/djangoapps/learner_home/utils.py | 33 +++++++ lms/djangoapps/learner_home/views.py | 65 ++++++++++++- 5 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 lms/djangoapps/learner_home/utils.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 9f6fbcf288..ac9f50d1ef 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -252,7 +252,7 @@ class TestCourseRecommendationApiView(SharedModuleStoreTestCase): 'marketing_url': 'https://www.edx.org/course/introduction-to-computer-science-and-programming-7' } - @mock.patch('lms.djangoapps.learner_dashboard.api.v0.views.get_personalized_course_recommendations', ) + @mock.patch('lms.djangoapps.learner_dashboard.api.v0.views.get_personalized_course_recommendations') @mock.patch('lms.djangoapps.learner_dashboard.api.v0.views.get_course_data') def test_no_recommendations_from_amplitude(self, mocked_get_course_data, mocked_get_personalized_course_recommendations): diff --git a/lms/djangoapps/learner_home/test_views.py b/lms/djangoapps/learner_home/test_views.py index ec013e3f13..996549b12b 100644 --- a/lms/djangoapps/learner_home/test_views.py +++ b/lms/djangoapps/learner_home/test_views.py @@ -2,20 +2,22 @@ from contextlib import contextmanager import json -from unittest import TestCase +from unittest import mock, TestCase 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 +from django.urls import reverse, reverse_lazy from django.utils import timezone +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from rest_framework.test import APITestCase from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory +from common.djangoapps.student.toggles import ENABLE_AMPLITUDE_RECOMMENDATIONS from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, UserFactory, @@ -42,14 +44,14 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import ( CourseOverviewFactory, ) from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangoapps.catalog.tests.factories import ( + CourseFactory as CatalogCourseFactory, +) from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase, ) from xmodule.modulestore.tests.factories import CourseFactory -from openedx.core.djangoapps.catalog.tests.factories import ( - CourseFactory as CatalogCourseFactory, -) ENTERPRISE_ENABLED = "ENABLE_ENTERPRISE_INTEGRATION" @@ -750,3 +752,86 @@ 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') + + 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_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_AMPLITUDE_RECOMMENDATIONS, active=False) + def test_waffle_flag_off(self): + """ + Verify API returns 400 if waffle flag is off. + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, None) + + @override_waffle_flag(ENABLE_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_no_recommendations_from_amplitude(self, mocked_get_course_data, + mocked_get_personalized_course_recommendations): + """ + Verify API returns 400 if no course recommendations from amplitude. + """ + mocked_get_personalized_course_recommendations.return_value = [False, []] + mocked_get_course_data.return_value = self.course_data + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, None) + + @override_waffle_flag(ENABLE_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) + self.assertEqual(response.data.get('is_personalized_recommendation'), True) + self.assertEqual(len(response.data.get('courses')), expected_recommendations_length) + + @override_waffle_flag(ENABLE_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 + course_keys = ['course-v1:IBM+PY0101EN+Run_0', 'course-v1:UQx+IELTSx+Run_0', 'course-v1:MITx+6.00.1x+Run_0', + 'course-v1:HarvardX+CS50P+Run_0', 'course-v1:Harvard+CS50z+Run_0', 'course-v1:TUMx+QPLS2x+Run_0'] + expected_recommendations = 4 + # enrolling in 6 courses + for course_key in course_keys: + CourseEnrollmentFactory(course_id=course_key, user=self.user) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.get('is_personalized_recommendation'), True) + self.assertEqual(len(response.data.get('courses')), expected_recommendations) diff --git a/lms/djangoapps/learner_home/urls.py b/lms/djangoapps/learner_home/urls.py index a8c5b0563a..5cf0340e38 100644 --- a/lms/djangoapps/learner_home/urls.py +++ b/lms/djangoapps/learner_home/urls.py @@ -12,4 +12,5 @@ urlpatterns = [ re_path( r"^mock/init/?", mock_views.InitializeView.as_view(), name="mock_initialize" ), + re_path(r"^recommendation/courses/$", views.CourseRecommendationApiView.as_view(), name="courses"), ] diff --git a/lms/djangoapps/learner_home/utils.py b/lms/djangoapps/learner_home/utils.py new file mode 100644 index 0000000000..ae529c9c5f --- /dev/null +++ b/lms/djangoapps/learner_home/utils.py @@ -0,0 +1,33 @@ +"""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, + } + try: + 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 + except Exception as ex: # pylint: disable=broad-except + log.warning(f'Cannot get recommendations from Amplitude: {ex}') + + return True, [] diff --git a/lms/djangoapps/learner_home/views.py b/lms/djangoapps/learner_home/views.py index 3fdfdffe8d..970fa66cdf 100644 --- a/lms/djangoapps/learner_home/views.py +++ b/lms/djangoapps/learner_home/views.py @@ -8,19 +8,25 @@ from django.core.exceptions import MultipleObjectsReturned from edx_django_utils import monitoring as monitoring_utils from opaque_keys.edx.keys import CourseKey from rest_framework.exceptions import PermissionDenied, NotFound +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.generics import RetrieveAPIView +from rest_framework.views import APIView +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.shortcuts import marketing_link from common.djangoapps.student.helpers import cert_info, get_resume_urls_for_enrollments -from common.djangoapps.student.models import get_user_by_username_or_email +from common.djangoapps.student.models import CourseEnrollment, get_user_by_username_or_email +from common.djangoapps.student.toggles import should_show_amplitude_recommendations from common.djangoapps.student.views.dashboard import ( complete_course_mode_info, get_course_enrollments, get_org_black_and_whitelist_for_site, get_filtered_course_entitlements, ) +from common.djangoapps.track import segment from common.djangoapps.util.milestones_helpers import ( get_pre_requisite_courses_not_completed, ) @@ -32,9 +38,11 @@ from lms.djangoapps.courseware.access_utils import ( check_course_open_for_learner, ) from lms.djangoapps.learner_home.serializers import LearnerDashboardSerializer +from lms.djangoapps.learner_home.utils import get_personalized_course_recommendations from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.programs.utils import ProgramProgressMeter +from openedx.core.djangoapps.catalog.utils import get_course_data from openedx.features.enterprise_support.api import ( enterprise_customer_from_session_or_learner_data, get_enterprise_learner_data_from_db, @@ -401,3 +409,58 @@ class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument learner_dash_data, context=context ).data return Response(response_data) + + +class CourseRecommendationApiView(APIView): + """ + **Example Request** + + GET /api/learner_home/recommendation/courses/ + """ + + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser,) + permission_classes = (IsAuthenticated,) + + def get(self, request): + """ Retrieves course recommendations details of a user in a specified course. """ + if not should_show_amplitude_recommendations(): + return Response(status=400) + + user_id = request.user.id + is_control, course_keys = get_personalized_course_recommendations(user_id) + + # 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 Response(status=400) + + 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') + }) + + segment.track(user_id, 'edx.bi.user.recommendations.count', {'count': len(recommended_courses)}) + return Response({'courses': recommended_courses, 'is_personalized_recommendation': not is_control}, status=200)