feat: Cross Product Recommendations Logic (#32003)
* feat: added get_cross_product_recommendations method * feat: Cross Product Recommendations Logic * fix: lint errors * fix: removed unused utility method get_programs_based_on_course * fix: lint errors * fix: views test case removed decorator * fix: edited serializer to no longer extend from existing * fix: included mocked data for tests and handled empty discovery responses * fix: removed settings import * test: add module docstring * test: removed log resultitng in failure * fix: moved recommendations dictionary to settings variable * fix: cleaned up logic for Cross Product Recommendations view * refactor: requested changes to view and serializer tests * fix: fixed list passed into serializer in cross product view * fix: added query string to course query and removed production course keys * fix: moved query string argument value to separate variable
This commit is contained in:
@@ -39,6 +39,24 @@ class RecommendedCourseSerializer(serializers.Serializer):
|
||||
return f"course/{url_slug}"
|
||||
|
||||
|
||||
class CrossProductCourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a cross product recommended course"""
|
||||
key = serializers.CharField()
|
||||
uuid = serializers.UUIDField()
|
||||
title = serializers.CharField()
|
||||
image = CourseImageSerializer()
|
||||
prospectusPath = serializers.SerializerMethodField()
|
||||
owners = serializers.ListField(
|
||||
child=CourseOwnersSerializer(), allow_empty=True
|
||||
)
|
||||
activeCourseRun = ActiveCourseRunSerializer(source="active_course_run")
|
||||
courseType = serializers.CharField(source="course_type")
|
||||
|
||||
def get_prospectusPath(self, instance):
|
||||
url_slug = instance.get("url_slug")
|
||||
return f"course/{url_slug}"
|
||||
|
||||
|
||||
class AboutPageRecommendationsSerializer(serializers.Serializer):
|
||||
"""Recommended courses for course about page"""
|
||||
|
||||
@@ -51,6 +69,13 @@ class AboutPageRecommendationsSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class CrossProductRecommendationsSerializer(serializers.Serializer):
|
||||
"""Cross product recommendation courses for course about page"""
|
||||
courses = serializers.ListField(
|
||||
child=CrossProductCourseSerializer(), allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class CourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a recommended course from the recommendation engine"""
|
||||
|
||||
|
||||
53
lms/djangoapps/learner_recommendations/tests/test_data.py
Normal file
53
lms/djangoapps/learner_recommendations/tests/test_data.py
Normal file
@@ -0,0 +1,53 @@
|
||||
""" Mocked data for testing """
|
||||
|
||||
mock_course_data = {
|
||||
"courses": [
|
||||
{
|
||||
"key": "edx+HL0",
|
||||
"uuid": "0f8cb2c9-589b-4d1e-88c1-b01a02db3a9c",
|
||||
"title": "Title 0",
|
||||
"image": {
|
||||
"src": "https://www.logo_image_url0.com"
|
||||
},
|
||||
"prospectusPath": "course/https://www.marketing_url0.com",
|
||||
"owners": [
|
||||
{
|
||||
"key": "org-0",
|
||||
"name": "org 0",
|
||||
"logoImageUrl": "https://discovery.com/organization/logos/org-0.png"
|
||||
}
|
||||
],
|
||||
"activeCourseRun": {
|
||||
"key": "course-v1:Test+2023_T0",
|
||||
"marketingUrl": "https://www.marketing_url0.com"
|
||||
},
|
||||
"courseType": "executive-education"
|
||||
},
|
||||
{
|
||||
"key": "edx+HL1",
|
||||
"uuid": "1f8cb2c9-589b-4d1e-88c1-b01a02db3a9c",
|
||||
"title": "Title 1",
|
||||
"image": {
|
||||
"src": "https://www.logo_image_url1.com"
|
||||
},
|
||||
"prospectusPath": "course/https://www.marketing_url1.com",
|
||||
"owners": [
|
||||
{
|
||||
"key": "org-1",
|
||||
"name": "org 1",
|
||||
"logoImageUrl": "https://discovery.com/organization/logos/org-1.png"
|
||||
}
|
||||
],
|
||||
"activeCourseRun": {
|
||||
"key": "course-v1:Test+2023_T1",
|
||||
"marketingUrl": "https://www.marketing_url1.com"
|
||||
},
|
||||
"courseType": "executive-education"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_cross_product_recommendation_keys = {
|
||||
"edx+HL0": ["edx+HL1", "edx+HL2"],
|
||||
"edx+BZ0": ["edx+BZ1", "edx+BZ2"],
|
||||
}
|
||||
@@ -6,7 +6,9 @@ from django.test import TestCase
|
||||
|
||||
from lms.djangoapps.learner_recommendations.serializers import (
|
||||
DashboardRecommendationsSerializer,
|
||||
CrossProductRecommendationsSerializer
|
||||
)
|
||||
from lms.djangoapps.learner_recommendations.tests.test_data import mock_course_data
|
||||
|
||||
|
||||
class TestDashboardRecommendationsSerializer(TestCase):
|
||||
@@ -81,3 +83,72 @@ class TestDashboardRecommendationsSerializer(TestCase):
|
||||
"isControl": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestCrossProductRecommendationsSerializer(TestCase):
|
||||
"""Tests for the Cross Product Recommendations Serializer"""
|
||||
|
||||
def mock_recommended_courses(self, num_of_courses):
|
||||
"""Course data mock"""
|
||||
|
||||
recommended_courses = []
|
||||
|
||||
for index in range(num_of_courses):
|
||||
recommended_courses.append(
|
||||
{
|
||||
"key": f"edx+HL{index}",
|
||||
"uuid": f"{index}f8cb2c9-589b-4d1e-88c1-b01a02db3a9c",
|
||||
"title": f"Title {index}",
|
||||
"image": {
|
||||
"src": f"https://www.logo_image_url{index}.com",
|
||||
},
|
||||
"url_slug": f"https://www.marketing_url{index}.com",
|
||||
"course_type": "executive-education",
|
||||
"owners": [
|
||||
{
|
||||
"key": f"org-{index}",
|
||||
"name": f"org {index}",
|
||||
"logo_image_url": f"https://discovery.com/organization/logos/org-{index}.png",
|
||||
},
|
||||
],
|
||||
"course_runs": [
|
||||
{
|
||||
"key": f"course-v1:Test+2023_T{index}",
|
||||
"marketing_url": f"https://www.marketing_url{index}.com",
|
||||
"availability": "Current",
|
||||
}
|
||||
],
|
||||
"active_course_run": {
|
||||
"key": f"course-v1:Test+2023_T{index}",
|
||||
"marketing_url": f"https://www.marketing_url{index}.com",
|
||||
"availability": "Current",
|
||||
},
|
||||
"location_restriction": None
|
||||
},
|
||||
)
|
||||
|
||||
return recommended_courses
|
||||
|
||||
def test_successful_serialization(self):
|
||||
courses = self.mock_recommended_courses(num_of_courses=2)
|
||||
|
||||
serialized_data = CrossProductRecommendationsSerializer({
|
||||
"courses": courses
|
||||
}).data
|
||||
|
||||
self.assertDictEqual(
|
||||
serialized_data,
|
||||
mock_course_data
|
||||
)
|
||||
|
||||
def test_no_course_data_serialization(self):
|
||||
serialized_data = CrossProductRecommendationsSerializer({
|
||||
"courses": []
|
||||
}).data
|
||||
|
||||
self.assertDictEqual(
|
||||
serialized_data,
|
||||
{
|
||||
"courses": []
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11,8 +11,10 @@ from lms.djangoapps.learner_recommendations.utils import (
|
||||
_has_country_restrictions,
|
||||
filter_recommended_courses,
|
||||
get_amplitude_course_recommendations,
|
||||
get_cross_product_recommendations
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from lms.djangoapps.learner_recommendations.tests.test_data import mock_cross_product_recommendation_keys
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -209,3 +211,18 @@ class TestFilterRecommendedCourses(ModuleStoreTestCase):
|
||||
expected_recommendations.append(self._mock_get_course_data(course_key))
|
||||
|
||||
assert filtered_courses == expected_recommendations
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestGetCrossProductRecommendationsMethod(TestCase):
|
||||
"""Test for get_cross_product_recommendations method"""
|
||||
|
||||
@ddt.data(
|
||||
("edx+HL0", ["edx+HL1", "edx+HL2"]),
|
||||
("edx+BZ0", ["edx+BZ1", "edx+BZ2"]),
|
||||
('NoKeyAssociated', None)
|
||||
)
|
||||
@patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@ddt.unpack
|
||||
def test_get_cross_product_recommendations_method(self, course_key, expected_response):
|
||||
assert get_cross_product_recommendations(course_key) == expected_response
|
||||
|
||||
@@ -15,10 +15,12 @@ from lms.djangoapps.learner_recommendations.toggles import (
|
||||
ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS,
|
||||
ENABLE_DASHBOARD_RECOMMENDATIONS,
|
||||
)
|
||||
from lms.djangoapps.learner_recommendations.tests.test_data import mock_cross_product_recommendation_keys
|
||||
|
||||
|
||||
class TestRecommendationsBase(APITestCase):
|
||||
"""Recommendations test base class"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
@@ -152,6 +154,161 @@ class TestAboutPageRecommendationsView(TestRecommendationsBase):
|
||||
assert segment_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed"
|
||||
|
||||
|
||||
class TestCrossProductRecommendationsView(APITestCase):
|
||||
"""Unit tests for the Cross Product Recommendations View"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.associated_course_keys = ["edx+HL1", "edx+HL2"]
|
||||
|
||||
def _get_url(self, course_key):
|
||||
"""
|
||||
Returns the url with a sepcific course id
|
||||
"""
|
||||
return reverse_lazy(
|
||||
"learner_recommendations:cross_product_recommendations",
|
||||
kwargs={'course_id': f'course-v1:{course_key}+Test_Course'}
|
||||
)
|
||||
|
||||
def _get_recommended_courses(self, num_of_courses_with_restriction=0):
|
||||
"""
|
||||
Returns an array of 2 discovery courses with or without country restrictions
|
||||
"""
|
||||
courses = []
|
||||
restriction_obj = {
|
||||
"restriction_type": "blocklist",
|
||||
"countries": ["CN"],
|
||||
"states": []
|
||||
}
|
||||
|
||||
for course_key in enumerate(self.associated_course_keys):
|
||||
location_restriction = restriction_obj if num_of_courses_with_restriction > 0 else None
|
||||
|
||||
courses.append({
|
||||
"key": course_key[1],
|
||||
"uuid": "6f8cb2c9-589b-4d1e-88c1-b01a02db3a9c",
|
||||
"title": f"Title {course_key[0]}",
|
||||
"image": {
|
||||
"src": "https://www.logo_image_url.com",
|
||||
},
|
||||
"url_slug": "https://www.marketing_url.com",
|
||||
"course_type": "executive-education",
|
||||
"owners": [
|
||||
{
|
||||
"key": "org-1",
|
||||
"name": "org 1",
|
||||
"logo_image_url": "https://discovery.com/organization/logos/org-1.png",
|
||||
},
|
||||
],
|
||||
"course_runs": [
|
||||
{
|
||||
"key": "course-v1:Test+2023_T2",
|
||||
"marketing_url": "https://www.marketing_url.com",
|
||||
"availability": "Current",
|
||||
}
|
||||
],
|
||||
"location_restriction": location_restriction
|
||||
})
|
||||
|
||||
if num_of_courses_with_restriction > 0:
|
||||
num_of_courses_with_restriction -= 1
|
||||
|
||||
return courses
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_successful_response(
|
||||
self, country_code_from_ip_mock, get_course_data_mock,
|
||||
):
|
||||
"""
|
||||
Verify 2 cross product course recommendations are returned.
|
||||
"""
|
||||
country_code_from_ip_mock.return_value = "za"
|
||||
mock_course_data = self._get_recommended_courses()
|
||||
get_course_data_mock.side_effect = [mock_course_data[0], mock_course_data[1]]
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
course_data = response_content["courses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(course_data), 2)
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_one_course_country_restriction_response(
|
||||
self, country_code_from_ip_mock, get_course_data_mock,
|
||||
):
|
||||
"""
|
||||
Verify 1 cross product course recommendation is returned
|
||||
if there is a location restriction for one course for the users country
|
||||
"""
|
||||
country_code_from_ip_mock.return_value = "cn"
|
||||
mock_course_data = self._get_recommended_courses(1)
|
||||
get_course_data_mock.side_effect = [mock_course_data[0], mock_course_data[1]]
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
course_data = response_content["courses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(course_data), 1)
|
||||
self.assertEqual(course_data[0]["title"], "Title 1")
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_both_course_country_restriction_response(
|
||||
self, country_code_from_ip_mock, get_course_data_mock,
|
||||
):
|
||||
"""
|
||||
Verify no courses are returned if both courses have a location restriction
|
||||
for the users country.
|
||||
"""
|
||||
country_code_from_ip_mock.return_value = "cn"
|
||||
mock_course_data = self._get_recommended_courses(2)
|
||||
|
||||
get_course_data_mock.side_effect = [mock_course_data[0], mock_course_data[1]]
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
course_data = response_content["courses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(course_data), 0)
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
def test_no_associated_course_response(self):
|
||||
"""
|
||||
Verify an empty array of courses is returned if there are no associated course keys.
|
||||
"""
|
||||
response = self.client.get(self._get_url('No+Associations'))
|
||||
response_content = json.loads(response.content)
|
||||
course_data = response_content["courses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(course_data), 0)
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_no_response_from_discovery(self, country_code_from_ip_mock, get_course_data_mock):
|
||||
"""
|
||||
Verify an empty array of courses is returned if discovery returns two empty dictionaries.
|
||||
"""
|
||||
country_code_from_ip_mock.return_value = "za"
|
||||
get_course_data_mock.side_effect = [{}, {}]
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
course_data = response_content["courses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(course_data), 0)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestDashboardRecommendationsApiView(TestRecommendationsBase):
|
||||
"""Unit tests for the course recommendations on learner home page."""
|
||||
@@ -251,7 +408,6 @@ class TestDashboardRecommendationsApiView(TestRecommendationsBase):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ urlpatterns = [
|
||||
re_path(fr'^amplitude/{settings.COURSE_ID_PATTERN}/$',
|
||||
views.AboutPageRecommendationsView.as_view(),
|
||||
name='amplitude_recommendations'),
|
||||
re_path(fr'^cross_product/{settings.COURSE_ID_PATTERN}/$',
|
||||
views.CrossProductRecommendationsView.as_view(),
|
||||
name='cross_product_recommendations'),
|
||||
re_path(r"^courses/$",
|
||||
views.DashboardRecommendationsApiView.as_view(),
|
||||
name="courses"),
|
||||
name="courses")
|
||||
]
|
||||
|
||||
@@ -202,3 +202,10 @@ def filter_recommended_courses(
|
||||
filtered_recommended_courses.append(course_data)
|
||||
|
||||
return filtered_recommended_courses
|
||||
|
||||
|
||||
def get_cross_product_recommendations(course_key):
|
||||
"""
|
||||
Helper method to get associated course keys based on the key passed
|
||||
"""
|
||||
return settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS.get(course_key)
|
||||
|
||||
@@ -10,6 +10,7 @@ from edx_rest_framework_extensions.auth.session.authentication import (
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
@@ -27,13 +28,16 @@ from lms.djangoapps.learner_recommendations.utils import (
|
||||
get_amplitude_course_recommendations,
|
||||
filter_recommended_courses,
|
||||
is_user_enrolled_in_ut_austin_masters_program,
|
||||
_has_country_restrictions,
|
||||
)
|
||||
from lms.djangoapps.learner_recommendations.serializers import CrossProductRecommendationsSerializer
|
||||
from lms.djangoapps.learner_recommendations.utils import get_cross_product_recommendations
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_data
|
||||
from lms.djangoapps.learner_recommendations.serializers import (
|
||||
AboutPageRecommendationsSerializer,
|
||||
DashboardRecommendationsSerializer,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -124,6 +128,69 @@ class AboutPageRecommendationsView(APIView):
|
||||
)
|
||||
|
||||
|
||||
class CrossProductRecommendationsView(APIView):
|
||||
"""
|
||||
**Example Request**
|
||||
|
||||
GET api/learner_recommendations/cross_product/{course_id}/
|
||||
"""
|
||||
|
||||
def _empty_response(self):
|
||||
return Response({"courses": []}, status=200)
|
||||
|
||||
def get(self, request, course_id):
|
||||
"""
|
||||
Returns cross product recommendation courses
|
||||
"""
|
||||
course_locator = CourseKey.from_string(course_id)
|
||||
course_key = f'{course_locator.org}+{course_locator.course}'
|
||||
|
||||
associated_course_keys = get_cross_product_recommendations(course_key)
|
||||
|
||||
if not associated_course_keys:
|
||||
return self._empty_response()
|
||||
|
||||
fields = [
|
||||
"key",
|
||||
"uuid",
|
||||
"title",
|
||||
"owners",
|
||||
"image",
|
||||
"url_slug",
|
||||
"course_type",
|
||||
"course_runs",
|
||||
"location_restriction",
|
||||
]
|
||||
query_string = {'marketable_course_runs_only': 1}
|
||||
course_data = [get_course_data(key, fields, query_string) for key in associated_course_keys]
|
||||
filtered_courses = [course for course in course_data if course and course.get("course_runs")]
|
||||
|
||||
ip_address = get_client_ip(request)[0]
|
||||
user_country_code = country_code_from_ip(ip_address).upper()
|
||||
|
||||
unrestricted_courses = []
|
||||
|
||||
for course in filtered_courses:
|
||||
if not _has_country_restrictions(course, user_country_code):
|
||||
unrestricted_courses.append(course)
|
||||
|
||||
if not unrestricted_courses:
|
||||
return self._empty_response()
|
||||
|
||||
for course in unrestricted_courses:
|
||||
course.update({
|
||||
"active_course_run": course.get("course_runs")[0]
|
||||
})
|
||||
|
||||
return Response(
|
||||
CrossProductRecommendationsSerializer(
|
||||
{
|
||||
"courses": unrestricted_courses
|
||||
}).data,
|
||||
status=200
|
||||
)
|
||||
|
||||
|
||||
class DashboardRecommendationsApiView(APIView):
|
||||
"""
|
||||
API to get personalized recommendations from Amplitude.
|
||||
|
||||
@@ -4789,6 +4789,9 @@ GENERAL_RECOMMENDATION = {}
|
||||
|
||||
GENERAL_RECOMMENDATIONS = []
|
||||
|
||||
### DEFAULT KEY DICTIONARY FOR CROSS_PRODUCT_RECOMMENDATIONS ###
|
||||
CROSS_PRODUCT_RECOMMENDATIONS_KEYS = {}
|
||||
|
||||
############### Settings for Retirement #####################
|
||||
# .. setting_name: RETIRED_USERNAME_PREFIX
|
||||
# .. setting_default: retired__user_
|
||||
|
||||
Reference in New Issue
Block a user