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:
Jody Bailey
2023-04-07 13:17:17 +02:00
committed by GitHub
parent bbcd3124a6
commit ba1c018a2e
9 changed files with 405 additions and 3 deletions

View File

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

View 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"],
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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