refactor: Reduce redundant code (#31975)

Move recommendations related code to learner_recommendations app.

VAN-1310
This commit is contained in:
Mubbshar Anwar
2023-03-24 16:18:57 +05:00
committed by GitHub
parent 2bb71390e5
commit 773f97d324
6 changed files with 531 additions and 15 deletions

View File

@@ -39,7 +39,7 @@ class RecommendedCourseSerializer(serializers.Serializer):
return f"course/{url_slug}"
class RecommendationsSerializer(serializers.Serializer):
class AboutPageRecommendationsSerializer(serializers.Serializer):
"""Recommended courses for course about page"""
courses = serializers.ListField(
@@ -49,3 +49,24 @@ class RecommendationsSerializer(serializers.Serializer):
source="is_control",
default=None
)
class CourseSerializer(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 DashboardRecommendationsSerializer(serializers.Serializer):
"""Recommended courses for learner dashboard"""
courses = serializers.ListField(
child=CourseSerializer(), allow_empty=True
)
isControl = serializers.BooleanField(
source="is_control",
default=None
)

View File

@@ -0,0 +1,83 @@
"""Tests for serializers for the Learner Recommendations"""
from uuid import uuid4
from django.test import TestCase
from lms.djangoapps.learner_recommendations.serializers import (
DashboardRecommendationsSerializer,
)
class TestDashboardRecommendationsSerializer(TestCase):
"""High-level tests for DashboardRecommendationsSerializer"""
@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": "http://edx.org/images/test.png",
"marketing_url": "http://edx.org/courses/AI",
"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 = DashboardRecommendationsSerializer(
{
"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 = DashboardRecommendationsSerializer(
{
"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

@@ -8,21 +8,17 @@ from edx_toggles.toggles.testutils import override_waffle_flag
from rest_framework.test import APITestCase
from unittest import mock
import ddt
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.student.toggles import ENABLE_FALLBACK_RECOMMENDATIONS
from lms.djangoapps.learner_recommendations.toggles import (
ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS,
ENABLE_DASHBOARD_RECOMMENDATIONS,
)
@override_waffle_flag(ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS, active=True)
class TestAmplitudeRecommendationsView(APITestCase):
"""Unit tests for the Amplitude recommendations API"""
url = reverse_lazy(
"learner_recommendations:amplitude_recommendations",
kwargs={'course_id': 'course-v1:test+TestX+Test_Course'}
)
class TestRecommendationsBase(APITestCase):
"""Recommendations test base class"""
def setUp(self):
super().setUp()
self.user = UserFactory()
@@ -40,6 +36,16 @@ class TestAmplitudeRecommendationsView(APITestCase):
"MichinX+101x",
]
@override_waffle_flag(ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS, active=True)
class TestAboutPageRecommendationsView(TestRecommendationsBase):
"""Unit tests for the Amplitude recommendations API"""
url = reverse_lazy(
"learner_recommendations:amplitude_recommendations",
kwargs={'course_id': 'course-v1:test+TestX+Test_Course'}
)
def _get_filtered_courses(self):
"""
Returns the filtered course data
@@ -144,3 +150,283 @@ class TestAmplitudeRecommendationsView(APITestCase):
# Verify that the segment event was fired
assert segment_mock.call_count == 1
assert segment_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed"
@ddt.ddt
class TestDashboardRecommendationsApiView(TestRecommendationsBase):
"""Unit tests for the course recommendations on learner home page."""
url = reverse_lazy("learner_recommendations:courses")
GENERAL_RECOMMENDATIONS = [
{
"course_key": "HogwartsX+6.00.1x",
"logo_image_url": "http://edx.org/images/test.png",
"marketing_url": "http://edx.org/courses/AI",
"title": "Defense Against the Dark Arts",
},
{
"course_key": "MonstersX+SC101EN",
"logo_image_url": "http://edx.org/images/test.png",
"marketing_url": "http://edx.org/courses/AI",
"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.course_run_keys = [f"course-v1:{course}+Run_0" for course in self.recommended_courses]
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_DASHBOARD_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_DASHBOARD_RECOMMENDATIONS, active=True)
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
@mock.patch(
"lms.djangoapps.learner_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_DASHBOARD_RECOMMENDATIONS, active=True)
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
@mock.patch(
"lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations",
mock.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_DASHBOARD_RECOMMENDATIONS, active=True)
@mock.patch(
"lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations"
)
@mock.patch("lms.djangoapps.learner_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_DASHBOARD_RECOMMENDATIONS, active=True)
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
@mock.patch(
"lms.djangoapps.learner_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_DASHBOARD_RECOMMENDATIONS, active=True)
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
@mock.patch(
"lms.djangoapps.learner_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_DASHBOARD_RECOMMENDATIONS, active=True)
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
@mock.patch(
"lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations"
)
@mock.patch("lms.djangoapps.learner_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_recommendations.views.segment.track")
@mock.patch("lms.djangoapps.learner_recommendations.views.filter_recommended_courses")
@mock.patch(
"lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations"
)
@override_waffle_flag(ENABLE_DASHBOARD_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_DASHBOARD_RECOMMENDATIONS, active=True)
@mock.patch(
"lms.djangoapps.learner_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

@@ -21,6 +21,24 @@ ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS = WaffleFlag(
f'{WAFFLE_FLAG_NAMESPACE}.enable_course_about_page_recommendations', __name__
)
# Waffle flag to enable to recommendation panel on learner dashboard
# .. toggle_name: learner_recommendations.enable_dashboard_recommendations
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable to recommendation panel on learner dashboard
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2023-03-24
# .. toggle_target_removal_date: None
# .. toggle_warning: None
# .. toggle_tickets: VAN-1310
ENABLE_DASHBOARD_RECOMMENDATIONS = WaffleFlag(
f"{WAFFLE_FLAG_NAMESPACE}.enable_dashboard_recommendations", __name__
)
def enable_dashboard_recommendations():
return ENABLE_DASHBOARD_RECOMMENDATIONS.is_enabled()
def enable_course_about_page_recommendations():
return ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS.is_enabled()

View File

@@ -11,6 +11,9 @@ app_name = "learner_recommendations"
urlpatterns = [
re_path(fr'^amplitude/{settings.COURSE_ID_PATTERN}/$',
views.AmplitudeRecommendationsView.as_view(),
views.AboutPageRecommendationsView.as_view(),
name='amplitude_recommendations'),
re_path(r"^courses/$",
views.DashboardRecommendationsApiView.as_view(),
name="courses"),
]

View File

@@ -9,26 +9,35 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthenticat
from edx_rest_framework_extensions.auth.session.authentication import (
SessionAuthenticationAllowInactiveUser,
)
from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication
from django.core.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from common.djangoapps.track import segment
from common.djangoapps.student.toggles import show_fallback_recommendations
from openedx.core.djangoapps.geoinfo.api import country_code_from_ip
from openedx.features.enterprise_support.utils import is_enterprise_learner
from lms.djangoapps.learner_recommendations.toggles import enable_course_about_page_recommendations
from lms.djangoapps.learner_recommendations.toggles import (
enable_course_about_page_recommendations,
enable_dashboard_recommendations,
)
from lms.djangoapps.learner_recommendations.utils import (
get_amplitude_course_recommendations,
filter_recommended_courses,
is_user_enrolled_in_ut_austin_masters_program,
)
from lms.djangoapps.learner_recommendations.serializers import (
AboutPageRecommendationsSerializer,
DashboardRecommendationsSerializer,
)
from lms.djangoapps.learner_recommendations.serializers import RecommendationsSerializer
log = logging.getLogger(__name__)
class AmplitudeRecommendationsView(APIView):
class AboutPageRecommendationsView(APIView):
"""
**Example Request**
@@ -105,7 +114,7 @@ class AmplitudeRecommendationsView(APIView):
)
return Response(
RecommendationsSerializer(
AboutPageRecommendationsSerializer(
{
"courses": recommended_courses,
"is_control": is_control,
@@ -113,3 +122,99 @@ class AmplitudeRecommendationsView(APIView):
).data,
status=200,
)
class DashboardRecommendationsApiView(APIView):
"""
API to get personalized recommendations from Amplitude.
**Example Request**
GET /api/learner_recommendations/courses/
"""
authentication_classes = (
JwtAuthentication,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated, NotJwtRestrictedApplication)
def get(self, request):
"""
Retrieves course recommendations details.
"""
if not enable_dashboard_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
log.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(
DashboardRecommendationsSerializer(
{
"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"),
}