Revert "Revert "refactor: recommendations code refactoring (#31990)" (#32047)" (#32135)

This reverts commit 3b991e6620.
This commit is contained in:
Mubbshar Anwar
2023-05-02 11:08:43 +05:00
committed by GitHub
parent 211c3c30bd
commit 73e32b5156
13 changed files with 3 additions and 944 deletions

View File

@@ -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)

View File

@@ -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(),

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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,
},
)

View File

@@ -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"), [])

View File

@@ -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",
),
]

View File

@@ -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"),
}

View File

@@ -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()

View File

@@ -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")
),
]

View File

@@ -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"
)

View File

@@ -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,