feat: integrate amplitude API with learner_home frontend [VAN-1124] (#31098)
This commit is contained in:
committed by
GitHub
parent
3bd94f78f9
commit
52de7e9066
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
33
lms/djangoapps/learner_home/utils.py
Normal file
33
lms/djangoapps/learner_home/utils.py
Normal file
@@ -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, []
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user