feat: new endpoint for cross product and amplitude recommendations (#32297)
* feat: cross product recommendations endpoint enhancement for amplitude recommendations * fix: general recommendations fix and linting fixes * fix: query string parameter value check fix * chore: updated positioning of docstring * fix: Adjusted docstring for CPR serializer * fix: Separated view, added new url, fallback recommendations fixes and more * fix: removed dangerous default value argument * chore: made necessary linting changes * chore: updated doctring description * fix: removed dangerous default argument * chore: updated doctring for ProductRecommendationsView
This commit is contained in:
@@ -39,8 +39,8 @@ class RecommendedCourseSerializer(serializers.Serializer):
|
||||
return f"course/{url_slug}"
|
||||
|
||||
|
||||
class CrossProductCourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a cross product recommended course"""
|
||||
class AboutPageProductRecommendationsSerializer(serializers.Serializer):
|
||||
"""Serializer for a cross product recommended course for the course about page"""
|
||||
key = serializers.CharField()
|
||||
uuid = serializers.UUIDField()
|
||||
title = serializers.CharField()
|
||||
@@ -57,6 +57,21 @@ class CrossProductCourseSerializer(serializers.Serializer):
|
||||
return f"course/{url_slug}"
|
||||
|
||||
|
||||
class LearnerDashboardProductRecommendationsSerializer(serializers.Serializer):
|
||||
"""Serializer for product recommendations for the Learner Dashboard"""
|
||||
title = serializers.CharField()
|
||||
image = CourseImageSerializer()
|
||||
prospectusPath = serializers.SerializerMethodField()
|
||||
owners = serializers.ListField(
|
||||
child=CourseOwnersSerializer(), allow_empty=True
|
||||
)
|
||||
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"""
|
||||
|
||||
@@ -70,9 +85,24 @@ class AboutPageRecommendationsSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class CrossProductRecommendationsSerializer(serializers.Serializer):
|
||||
"""Cross product recommendation courses for course about page"""
|
||||
"""
|
||||
Cross product recommendation courses for course about page
|
||||
"""
|
||||
courses = serializers.ListField(
|
||||
child=CrossProductCourseSerializer(), allow_empty=True
|
||||
child=AboutPageProductRecommendationsSerializer(), allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class CrossProductAndAmplitudeRecommendationsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Cross product recommendation courses and
|
||||
Amplitude recommendations for Learner Dashboard
|
||||
"""
|
||||
crossProductCourses = serializers.ListField(
|
||||
child=LearnerDashboardProductRecommendationsSerializer(), allow_empty=True
|
||||
)
|
||||
amplitudeCourses = serializers.ListField(
|
||||
child=LearnerDashboardProductRecommendationsSerializer(), allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,50 +1,154 @@
|
||||
""" 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"
|
||||
mock_course_data = [
|
||||
{
|
||||
"key": "edx+HL0",
|
||||
"uuid": "0f8cb2c9-589b-4d1e-88c1-b01a02db3a9c",
|
||||
"title": "Title 0",
|
||||
"image": {
|
||||
"src": "https://www.logo_image_url0.com"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
"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_data = [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"courseType": "executive-education"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"courseType": "executive-education"
|
||||
},
|
||||
]
|
||||
|
||||
mock_amplitude_data = [
|
||||
*mock_cross_product_data,
|
||||
{
|
||||
"title": "Title 2",
|
||||
"image": {
|
||||
"src": "https://www.logo_image_url2.com"
|
||||
},
|
||||
"prospectusPath": "course/https://www.marketing_url2.com",
|
||||
"owners": [
|
||||
{
|
||||
"key": "org-2",
|
||||
"name": "org 2",
|
||||
"logoImageUrl": "https://discovery.com/organization/logos/org-2.png"
|
||||
}
|
||||
],
|
||||
"courseType": "executive-education"
|
||||
},
|
||||
{
|
||||
"title": "Title 3",
|
||||
"image": {
|
||||
"src": "https://www.logo_image_url3.com"
|
||||
},
|
||||
"prospectusPath": "course/https://www.marketing_url3.com",
|
||||
"owners": [
|
||||
{
|
||||
"key": "org-3",
|
||||
"name": "org 3",
|
||||
"logoImageUrl": "https://discovery.com/organization/logos/org-3.png"
|
||||
}
|
||||
],
|
||||
"courseType": "executive-education"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def get_general_recommendations():
|
||||
"""Returns 5 general recommendations with the necessary fields"""
|
||||
|
||||
courses = []
|
||||
|
||||
base_course = {
|
||||
"course_key": "MITx+1.00",
|
||||
"title": "Introduction to Computer Science and Programming Using Python",
|
||||
"url_slug": "introduction-to-computer-science-and-programming-7",
|
||||
"course_type": "credit-verified-audit",
|
||||
"logo_image_url": "https://discovery.com/organization/logos/org-1.png",
|
||||
"marketing_url": "https://www.marketing_url.com",
|
||||
"owners": [
|
||||
{
|
||||
"key": "MITx",
|
||||
"name": "Massachusetts Institute of Technology",
|
||||
"logo_image_url": "https://discovery.com/organization/logos/org-1.png",
|
||||
}
|
||||
],
|
||||
"image": {
|
||||
"src": "https://link.to.an.image.png"
|
||||
},
|
||||
}
|
||||
|
||||
for _ in range(5):
|
||||
courses.append(base_course)
|
||||
|
||||
return courses
|
||||
|
||||
mock_amplitude_and_cross_product_course_data = {
|
||||
"crossProductCourses": mock_cross_product_data,
|
||||
"amplitudeCourses": mock_amplitude_data
|
||||
}
|
||||
|
||||
mock_cross_product_course_data = {
|
||||
"courses": mock_course_data
|
||||
}
|
||||
|
||||
mock_cross_product_recommendation_keys = {
|
||||
|
||||
@@ -6,9 +6,13 @@ from django.test import TestCase
|
||||
|
||||
from lms.djangoapps.learner_recommendations.serializers import (
|
||||
DashboardRecommendationsSerializer,
|
||||
CrossProductRecommendationsSerializer
|
||||
CrossProductRecommendationsSerializer,
|
||||
CrossProductAndAmplitudeRecommendationsSerializer
|
||||
)
|
||||
from lms.djangoapps.learner_recommendations.tests.test_data import (
|
||||
mock_amplitude_and_cross_product_course_data,
|
||||
mock_cross_product_course_data
|
||||
)
|
||||
from lms.djangoapps.learner_recommendations.tests.test_data import mock_course_data
|
||||
|
||||
|
||||
class TestDashboardRecommendationsSerializer(TestCase):
|
||||
@@ -85,10 +89,13 @@ class TestDashboardRecommendationsSerializer(TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCrossProductRecommendationsSerializer(TestCase):
|
||||
"""Tests for the Cross Product Recommendations Serializer"""
|
||||
class TestCrossProductRecommendationsSerializers(TestCase):
|
||||
"""
|
||||
Tests for the CrossProductRecommendationsSerializer
|
||||
and CrossProductAndAmplitudeRecommendations Serializer
|
||||
"""
|
||||
|
||||
def mock_recommended_courses(self, num_of_courses):
|
||||
def mock_recommended_courses(self, num_of_courses=2, amplitude_courses=False):
|
||||
"""Course data mock"""
|
||||
|
||||
recommended_courses = []
|
||||
@@ -127,28 +134,73 @@ class TestCrossProductRecommendationsSerializer(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
if amplitude_courses:
|
||||
keys_to_remove = ["active_course_run", "key", "uuid"]
|
||||
amplitude_courses = []
|
||||
|
||||
for course in recommended_courses:
|
||||
new_course = {key: value for key, value in course.items() if key not in keys_to_remove}
|
||||
amplitude_courses.append(new_course)
|
||||
|
||||
return amplitude_courses
|
||||
|
||||
return recommended_courses
|
||||
|
||||
def test_successful_serialization(self):
|
||||
def test_successful_cross_product_recommendation_serialization(self):
|
||||
"""Test that course data serializes correctly for CrossProductRecommendationSerializer"""
|
||||
courses = self.mock_recommended_courses(num_of_courses=2)
|
||||
|
||||
serialized_data = CrossProductRecommendationsSerializer({
|
||||
"courses": courses
|
||||
"courses": courses,
|
||||
}).data
|
||||
|
||||
self.assertDictEqual(
|
||||
serialized_data,
|
||||
mock_course_data
|
||||
mock_cross_product_course_data
|
||||
)
|
||||
|
||||
def test_no_course_data_serialization(self):
|
||||
def test_successful_cross_product_and_amplitude_recommendations_serializer(self):
|
||||
"""Test that course data serializes correctly for CrossProductAndAmplitudeRecommendationSerializer"""
|
||||
|
||||
cross_product_courses = self.mock_recommended_courses(num_of_courses=2)
|
||||
amplitude_courses = self.mock_recommended_courses(num_of_courses=4, amplitude_courses=True)
|
||||
|
||||
serialized_data = CrossProductAndAmplitudeRecommendationsSerializer({
|
||||
"crossProductCourses": cross_product_courses,
|
||||
"amplitudeCourses": amplitude_courses,
|
||||
}).data
|
||||
|
||||
self.assertDictEqual(
|
||||
serialized_data,
|
||||
mock_amplitude_and_cross_product_course_data
|
||||
)
|
||||
|
||||
def test_no_cross_product_course_serialization(self):
|
||||
"""Tests that empty course data for CrossProductRecommendationsSerializer serializes properly"""
|
||||
|
||||
serialized_data = CrossProductRecommendationsSerializer({
|
||||
"courses": []
|
||||
"courses": [],
|
||||
}).data
|
||||
|
||||
self.assertDictEqual(
|
||||
serialized_data,
|
||||
{
|
||||
"courses": []
|
||||
"courses": [],
|
||||
},
|
||||
)
|
||||
|
||||
def test_no_amplitude_and_cross_product_and_course_serialization(self):
|
||||
"""Tests that empty course data for CrossProductRecommendationsSerializer serializes properly"""
|
||||
|
||||
serialized_data = CrossProductAndAmplitudeRecommendationsSerializer({
|
||||
"crossProductCourses": [],
|
||||
"amplitudeCourses": []
|
||||
}).data
|
||||
|
||||
self.assertDictEqual(
|
||||
serialized_data,
|
||||
{
|
||||
"crossProductCourses": [],
|
||||
"amplitudeCourses": []
|
||||
},
|
||||
)
|
||||
|
||||
@@ -15,7 +15,10 @@ 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
|
||||
from lms.djangoapps.learner_recommendations.tests.test_data import (
|
||||
mock_cross_product_recommendation_keys,
|
||||
get_general_recommendations
|
||||
)
|
||||
|
||||
|
||||
class TestRecommendationsBase(APITestCase):
|
||||
@@ -331,6 +334,312 @@ class TestCrossProductRecommendationsView(APITestCase):
|
||||
self.assertEqual(len(course_data), 0)
|
||||
|
||||
|
||||
class TestProductRecommendationsView(APITestCase):
|
||||
"""Unit tests for ProductRecommendations View"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
self.associated_course_keys = ["edx+HL1", "edx+HL2"]
|
||||
self.amplitude_keys = [
|
||||
"edx+CS0",
|
||||
"edx+CS10",
|
||||
"edx+CS20",
|
||||
"edx+CS30",
|
||||
"edx+CS40",
|
||||
"edx+CS50",
|
||||
"edx+CS60",
|
||||
"edx+CS70",
|
||||
"edx+CS80",
|
||||
"edx+CS90",
|
||||
]
|
||||
self.amplitude_course_run_keys = [f"course-v1:{course_key}+2023_T2" for course_key in self.amplitude_keys]
|
||||
self.enrolled_course_run_keys = self.amplitude_course_run_keys[3:8]
|
||||
self.enrolled_course_keys = self.amplitude_keys[3:8]
|
||||
self.amplitude_location_restriction_keys = self.amplitude_keys[0:3]
|
||||
self.cross_product_location_restriction_keys = self.associated_course_keys[0]
|
||||
|
||||
def _get_url(self, course_key):
|
||||
"""
|
||||
Returns the url with a sepcific course id
|
||||
"""
|
||||
return reverse_lazy(
|
||||
"learner_recommendations:product_recommendations",
|
||||
kwargs={'course_id': f'course-v1:{course_key}+Test_Course'}
|
||||
)
|
||||
|
||||
def _get_product_recommendations(self, course_keys, keys_with_restriction=None):
|
||||
"""
|
||||
Returns course data based on the number of course keys passed in
|
||||
with a location restriction object if a list of keys for location restriction courses is passed in
|
||||
"""
|
||||
courses = []
|
||||
|
||||
for key in course_keys:
|
||||
course = {
|
||||
"title": f"Title for {key}",
|
||||
"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": f"course-v1:{key}+2023_T2",
|
||||
"marketing_url": "https://www.marketing_url.com",
|
||||
"availability": "Current",
|
||||
"uuid": "jh76b2c9-589b-4d1e-88c1-b01a02db3a9c",
|
||||
"status": "published"
|
||||
}
|
||||
],
|
||||
}
|
||||
if keys_with_restriction and key in keys_with_restriction:
|
||||
course.update({
|
||||
"location_restriction": {
|
||||
"restriction_type": "blocklist",
|
||||
"countries": ["CN"],
|
||||
"states": []
|
||||
}
|
||||
})
|
||||
|
||||
courses.append(course)
|
||||
|
||||
return courses
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_successful_response(
|
||||
self,
|
||||
country_code_from_ip_mock,
|
||||
get_amplitude_course_recommendations_mock,
|
||||
get_course_data_view_mock,
|
||||
get_course_data_util_mock,
|
||||
get_user_enrolled_course_keys_mock,
|
||||
):
|
||||
"""
|
||||
Verify 2 cross product course recommendations are returned
|
||||
and 4 amplitude courses are returned
|
||||
"""
|
||||
country_code_from_ip_mock.return_value = "za"
|
||||
get_user_enrolled_course_keys_mock.return_value = []
|
||||
get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys]
|
||||
|
||||
mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys)
|
||||
mock_amplitude_course_data = self._get_product_recommendations(self.amplitude_keys)
|
||||
get_course_data_view_mock.side_effect = mock_cross_product_course_data
|
||||
get_course_data_util_mock.side_effect = mock_amplitude_course_data
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
cross_product_course_data = response_content["crossProductCourses"]
|
||||
amplitude_course_data = response_content["amplitudeCourses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(cross_product_course_data), 2)
|
||||
self.assertEqual(len(amplitude_course_data), 4)
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_successful_course_filtering(
|
||||
self,
|
||||
country_code_from_ip_mock,
|
||||
get_amplitude_course_recommendations_mock,
|
||||
get_course_data_view_mock,
|
||||
get_course_data_util_mock,
|
||||
get_user_enrolled_course_keys_mock,
|
||||
):
|
||||
"""
|
||||
Verify 1 cross product course recommendation is returned
|
||||
and 2 amplitude courses are returned with filtering done for
|
||||
enrolled courses and courses with country restrictions
|
||||
"""
|
||||
country_code_from_ip_mock.return_value = "cn"
|
||||
get_user_enrolled_course_keys_mock.return_value = self.enrolled_course_run_keys
|
||||
get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys]
|
||||
|
||||
mock_cross_product_course_data = self._get_product_recommendations(
|
||||
self.associated_course_keys, self.cross_product_location_restriction_keys
|
||||
)
|
||||
mock_amplitude_course_data = self._get_product_recommendations(
|
||||
self.amplitude_keys, self.amplitude_location_restriction_keys
|
||||
)
|
||||
get_course_data_view_mock.side_effect = mock_cross_product_course_data
|
||||
get_course_data_util_mock.side_effect = mock_amplitude_course_data
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
cross_product_course_data = response_content["crossProductCourses"]
|
||||
amplitude_course_data = response_content["amplitudeCourses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(cross_product_course_data), 1)
|
||||
self.assertEqual(len(amplitude_course_data), 2)
|
||||
for course in amplitude_course_data:
|
||||
course_key = course["title"][2]
|
||||
assert course_key not in [*self.amplitude_location_restriction_keys, *self.enrolled_course_keys]
|
||||
for course in cross_product_course_data:
|
||||
course_key = course["title"][2]
|
||||
assert course_key not in self.cross_product_location_restriction_keys
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", get_general_recommendations())
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_fallback_recommendations_when_enrolled_courses_removed(
|
||||
self,
|
||||
country_code_from_ip_mock,
|
||||
get_amplitude_course_recommendations_mock,
|
||||
get_course_data_view_mock,
|
||||
get_course_data_util_mock,
|
||||
get_user_enrolled_course_keys_mock
|
||||
):
|
||||
"""
|
||||
Verify 2 cross product course recommendations are returned
|
||||
and 4 fallback amplitude recommendations are returned if no courses are left
|
||||
after filtering due to courses being already enrolled in
|
||||
"""
|
||||
|
||||
country_code_from_ip_mock.return_value = "za"
|
||||
get_user_enrolled_course_keys_mock.return_value = self.amplitude_course_run_keys
|
||||
get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys]
|
||||
|
||||
mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys)
|
||||
mock_amplitude_course_data = self._get_product_recommendations(self.amplitude_keys)
|
||||
get_course_data_view_mock.side_effect = mock_cross_product_course_data
|
||||
get_course_data_util_mock.side_effect = mock_amplitude_course_data
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
cross_product_course_data = response_content["crossProductCourses"]
|
||||
amplitude_course_data = response_content["amplitudeCourses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(cross_product_course_data), 2)
|
||||
self.assertEqual(len(amplitude_course_data), 4)
|
||||
for course in amplitude_course_data:
|
||||
self.assertEqual(course["title"], "Introduction to Computer Science and Programming Using Python")
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", get_general_recommendations())
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_fallback_recommendations_when_error_querying_amplitude(
|
||||
self,
|
||||
country_code_from_ip_mock,
|
||||
get_amplitude_course_recommendations_mock,
|
||||
get_course_data_mock,
|
||||
):
|
||||
"""
|
||||
Verify 2 cross product course recommendations are returned
|
||||
and 4 fallback amplitude recommendations are returned
|
||||
if there was an error querying amplitude for recommendations
|
||||
"""
|
||||
|
||||
country_code_from_ip_mock.return_value = "za"
|
||||
get_amplitude_course_recommendations_mock.side_effect = Exception()
|
||||
|
||||
mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys)
|
||||
get_course_data_mock.side_effect = mock_cross_product_course_data
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
cross_product_course_data = response_content["crossProductCourses"]
|
||||
amplitude_course_data = response_content["amplitudeCourses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(cross_product_course_data), 2)
|
||||
self.assertEqual(len(amplitude_course_data), 4)
|
||||
for course in amplitude_course_data:
|
||||
self.assertEqual(course["title"], "Introduction to Computer Science and Programming Using Python")
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", get_general_recommendations())
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_fallback_recommendations_when_no_amplitude_recommended_keys(
|
||||
self,
|
||||
country_code_from_ip_mock,
|
||||
get_amplitude_course_recommendations_mock,
|
||||
get_course_data_mock,
|
||||
):
|
||||
"""
|
||||
Verify 2 cross product course recommendations are returned
|
||||
and 4 fallback amplitude recommendations are returned
|
||||
if amplitude gave back no course keys
|
||||
"""
|
||||
|
||||
country_code_from_ip_mock.return_value = "za"
|
||||
get_amplitude_course_recommendations_mock.side_effect = [False, True, []]
|
||||
|
||||
mock_cross_product_course_data = self._get_product_recommendations(self.associated_course_keys)
|
||||
get_course_data_mock.side_effect = mock_cross_product_course_data
|
||||
|
||||
response = self.client.get(self._get_url('edx+HL0'))
|
||||
response_content = json.loads(response.content)
|
||||
cross_product_course_data = response_content["crossProductCourses"]
|
||||
amplitude_course_data = response_content["amplitudeCourses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(cross_product_course_data), 2)
|
||||
self.assertEqual(len(amplitude_course_data), 4)
|
||||
for course in amplitude_course_data:
|
||||
self.assertEqual(course["title"], "Introduction to Computer Science and Programming Using Python")
|
||||
|
||||
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils._get_user_enrolled_course_keys")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.utils.get_course_data")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.get_amplitude_course_recommendations")
|
||||
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
|
||||
def test_response_with_amplitude_and_no_cross_product_courses(
|
||||
self,
|
||||
country_code_from_ip_mock,
|
||||
get_amplitude_course_recommendations_mock,
|
||||
get_course_data_mock,
|
||||
get_user_enrolled_course_keys_mock
|
||||
):
|
||||
"""
|
||||
Verify that if no cross product courses are returned,
|
||||
then 4 fallback amplitude recommendations will still be returned
|
||||
"""
|
||||
|
||||
country_code_from_ip_mock.return_value = "za"
|
||||
get_user_enrolled_course_keys_mock.return_value = self.enrolled_course_run_keys
|
||||
get_amplitude_course_recommendations_mock.return_value = [False, True, self.amplitude_keys]
|
||||
|
||||
mock_amplitude_course_data = self._get_product_recommendations(self.amplitude_keys)
|
||||
get_course_data_mock.side_effect = mock_amplitude_course_data
|
||||
|
||||
response = self.client.get(self._get_url('No+Association'))
|
||||
response_content = json.loads(response.content)
|
||||
cross_product_course_data = response_content["crossProductCourses"]
|
||||
amplitude_course_data = response_content["amplitudeCourses"]
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(cross_product_course_data), 0)
|
||||
self.assertEqual(len(amplitude_course_data), 4)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestDashboardRecommendationsApiView(TestRecommendationsBase):
|
||||
"""Unit tests for the course recommendations on learner home page."""
|
||||
|
||||
@@ -16,7 +16,10 @@ urlpatterns = [
|
||||
re_path(fr'^cross_product/{settings.COURSE_ID_PATTERN}/$',
|
||||
views.CrossProductRecommendationsView.as_view(),
|
||||
name='cross_product_recommendations'),
|
||||
re_path(fr'^product_recommendations/{settings.COURSE_ID_PATTERN}/$',
|
||||
views.ProductRecommendationsView.as_view(),
|
||||
name='product_recommendations'),
|
||||
re_path(r"^courses/$",
|
||||
views.DashboardRecommendationsApiView.as_view(),
|
||||
name="courses")
|
||||
name="courses"),
|
||||
]
|
||||
|
||||
@@ -150,6 +150,7 @@ def filter_recommended_courses(
|
||||
recommendation_count=10,
|
||||
user_country_code=None,
|
||||
request_course_key=None,
|
||||
course_fields=None,
|
||||
):
|
||||
"""
|
||||
Returns the filtered course recommendations. The unfiltered course keys
|
||||
@@ -164,6 +165,7 @@ def filter_recommended_courses(
|
||||
recommendation_count: the maximum count of recommendations to be returned
|
||||
user_country_code: if provided, will apply location restrictions to recommendations
|
||||
request_course_key: if provided, will filter out that course from recommendations (used for course about page)
|
||||
fields: if provided, collects those fields on each course being queried, otherwise collects default fields
|
||||
|
||||
Returns:
|
||||
filtered_recommended_courses (list): A list of filtered course objects.
|
||||
@@ -180,7 +182,7 @@ def filter_recommended_courses(
|
||||
"location_restriction",
|
||||
"marketing_url",
|
||||
"programs",
|
||||
]
|
||||
] if not course_fields else course_fields
|
||||
|
||||
# Filter out enrolled courses .
|
||||
course_keys_to_filter_out = _get_user_enrolled_course_keys(user)
|
||||
|
||||
@@ -19,24 +19,26 @@ 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.core.djangoapps.catalog.utils import get_course_data
|
||||
from openedx.features.enterprise_support.utils import is_enterprise_learner
|
||||
|
||||
from lms.djangoapps.learner_recommendations.toggles import (
|
||||
enable_course_about_page_recommendations,
|
||||
enable_dashboard_recommendations,
|
||||
)
|
||||
from lms.djangoapps.learner_recommendations.utils import (
|
||||
_has_country_restrictions,
|
||||
get_amplitude_course_recommendations,
|
||||
filter_recommended_courses,
|
||||
is_user_enrolled_in_ut_austin_masters_program,
|
||||
_has_country_restrictions,
|
||||
get_cross_product_recommendations,
|
||||
get_active_course_run
|
||||
get_active_course_run,
|
||||
)
|
||||
from lms.djangoapps.learner_recommendations.serializers import CrossProductRecommendationsSerializer
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_data
|
||||
from lms.djangoapps.learner_recommendations.serializers import (
|
||||
AboutPageRecommendationsSerializer,
|
||||
DashboardRecommendationsSerializer,
|
||||
CrossProductAndAmplitudeRecommendationsSerializer,
|
||||
CrossProductRecommendationsSerializer,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -192,6 +194,101 @@ class CrossProductRecommendationsView(APIView):
|
||||
)
|
||||
|
||||
|
||||
class ProductRecommendationsView(APIView):
|
||||
"""
|
||||
**Example Request**
|
||||
|
||||
GET api/learner_recommendations/product_recommendations/{course_id}/
|
||||
"""
|
||||
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
permission_classes = (IsAuthenticated, NotJwtRestrictedApplication)
|
||||
|
||||
fields = [
|
||||
"title",
|
||||
"owners",
|
||||
"image",
|
||||
"url_slug",
|
||||
"course_type",
|
||||
"course_runs",
|
||||
"location_restriction",
|
||||
]
|
||||
|
||||
def _get_amplitude_recommendations(self, user, user_country_code):
|
||||
"""
|
||||
Helper for getting amplitude recommendations
|
||||
"""
|
||||
|
||||
fallback_recommendations = settings.GENERAL_RECOMMENDATIONS[0:4]
|
||||
|
||||
try:
|
||||
_, _, 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 fallback_recommendations
|
||||
|
||||
if not course_keys:
|
||||
return fallback_recommendations
|
||||
|
||||
filtered_courses = filter_recommended_courses(
|
||||
user, course_keys, recommendation_count=4, user_country_code=user_country_code, course_fields=self.fields
|
||||
)
|
||||
|
||||
return filtered_courses if len(filtered_courses) > 0 else fallback_recommendations
|
||||
|
||||
def _get_cross_product_recommendations(self, course_key, user_country_code):
|
||||
"""
|
||||
Helper for getting cross product recommendations
|
||||
"""
|
||||
|
||||
associated_course_keys = get_cross_product_recommendations(course_key)
|
||||
|
||||
if not associated_course_keys:
|
||||
return []
|
||||
|
||||
course_data = [get_course_data(key, self.fields) for key in associated_course_keys]
|
||||
filtered_cross_product_courses = []
|
||||
|
||||
for course in course_data:
|
||||
if (
|
||||
course
|
||||
and course.get("course_runs", [])
|
||||
and not _has_country_restrictions(course, user_country_code)
|
||||
):
|
||||
|
||||
filtered_cross_product_courses.append(course)
|
||||
|
||||
return filtered_cross_product_courses
|
||||
|
||||
def get(self, request, course_id):
|
||||
"""
|
||||
Returns cross product and amplitude recommendation courses
|
||||
"""
|
||||
|
||||
ip_address = get_client_ip(request)[0]
|
||||
user_country_code = country_code_from_ip(ip_address).upper()
|
||||
course_locator = CourseKey.from_string(course_id)
|
||||
course_key = f'{course_locator.org}+{course_locator.course}'
|
||||
|
||||
amplitude_recommendations = self._get_amplitude_recommendations(request.user, user_country_code)
|
||||
cross_product_recommendations = self._get_cross_product_recommendations(course_key, user_country_code)
|
||||
|
||||
return Response(
|
||||
CrossProductAndAmplitudeRecommendationsSerializer(
|
||||
{
|
||||
"crossProductCourses": cross_product_recommendations,
|
||||
"amplitudeCourses": amplitude_recommendations
|
||||
}
|
||||
).data,
|
||||
status=200
|
||||
)
|
||||
|
||||
|
||||
class DashboardRecommendationsApiView(APIView):
|
||||
"""
|
||||
API to get personalized recommendations from Amplitude.
|
||||
|
||||
Reference in New Issue
Block a user