Skip to content

Commit

Permalink
Revert "refactor: recommendations code refactoring (openedx#31990)" (o…
Browse files Browse the repository at this point in the history
…penedx#32047)

This reverts commit 20b1e65.
  • Loading branch information
mubbsharanwar authored Apr 11, 2023
1 parent 6cae1fa commit 3b991e6
Show file tree
Hide file tree
Showing 13 changed files with 944 additions and 3 deletions.
217 changes: 217 additions & 0 deletions lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
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 (
Expand All @@ -17,6 +20,7 @@
CourseEnrollmentFactory,
UserFactory,
)
from common.djangoapps.student.toggles import ENABLE_FALLBACK_RECOMMENDATIONS
from lms.djangoapps.program_enrollments.rest_api.v1.tests.test_views import (
ProgramCacheMixin,
)
Expand Down Expand Up @@ -239,3 +243,216 @@ def test_program_empty_list_if_no_enterprise_enrollments(self):
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)
4 changes: 3 additions & 1 deletion lms/djangoapps/learner_dashboard/api/v0/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@

from lms.djangoapps.learner_dashboard.api.v0.views import (
Programs,
ProgramProgressDetailView
ProgramProgressDetailView,
CourseRecommendationApiView
)

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(),
Expand Down
114 changes: 114 additions & 0 deletions lms/djangoapps/learner_dashboard/api/v0/views.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
""" 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
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,
get_industry_and_credit_pathways,
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__)
Expand Down Expand Up @@ -343,3 +356,104 @@ def get(self, request, program_uuid):
'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
)
Empty file.
Loading

0 comments on commit 3b991e6

Please sign in to comment.