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:
Jody Bailey
2023-06-05 16:31:11 +02:00
committed by GitHub
parent 585b96583a
commit c7fc04968f
7 changed files with 663 additions and 66 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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