This reverts commit 3b991e6620.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<enterprise_uuid>{UUID_REGEX_PATTERN})/$',
|
||||
Programs.as_view(),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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"), [])
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user