feat: [VAN-1259] Amplitude course recommendation api for course about page (#31650)
This commit is contained in:
@@ -290,7 +290,7 @@ class TestCourseRecommendationApiView(TestCase):
|
||||
filtered_course = []
|
||||
for course_key in self.recommended_courses[:5]:
|
||||
filtered_course.append({
|
||||
"course_key": course_key,
|
||||
"key": course_key,
|
||||
"title": f"Title for {course_key}",
|
||||
"logo_image_url": "https://www.logo_image_url.com",
|
||||
"marketing_url": "https://www.marketing_url.com",
|
||||
@@ -398,7 +398,8 @@ class TestCourseRecommendationApiView(TestCase):
|
||||
{
|
||||
"is_control": False,
|
||||
"amplitude_recommendations": True,
|
||||
"course_key_array": self.recommended_courses[:5],
|
||||
"course_key_array": [course.get("key") for course in
|
||||
self._get_filtered_courses()[:expected_recommendations]],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -401,6 +401,16 @@ class CourseRecommendationApiView(APIView):
|
||||
status=200,
|
||||
)
|
||||
|
||||
def _course_data(self, course):
|
||||
"""Helper method for personalized recommendation response"""
|
||||
return {
|
||||
"course_key": course.get("key"),
|
||||
"title": course.get("title"),
|
||||
"logo_image_url": course.get("owners")[0]["logo_image_url"] if course.get(
|
||||
"owners") else "",
|
||||
"marketing_url": course.get("marketing_url"),
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
"""Retrieves course recommendations details of a user in a specified course."""
|
||||
user_id = request.user.id
|
||||
@@ -427,12 +437,13 @@ class CourseRecommendationApiView(APIView):
|
||||
user_id, is_control, fallback_recommendations
|
||||
)
|
||||
|
||||
recommended_courses = filter_recommended_courses(request.user, course_keys)
|
||||
if not recommended_courses:
|
||||
filtered_courses = filter_recommended_courses(request.user, course_keys, recommendation_count=5)
|
||||
if not filtered_courses:
|
||||
return self._general_recommendations_response(
|
||||
user_id, is_control, fallback_recommendations
|
||||
)
|
||||
|
||||
recommended_courses = list(map(self._course_data, filtered_courses))
|
||||
self._emit_recommendations_viewed_event(
|
||||
user_id, is_control, recommended_courses
|
||||
)
|
||||
|
||||
@@ -93,7 +93,7 @@ class TestCourseRecommendationApiView(TestCase):
|
||||
filtered_course = []
|
||||
for course_key in self.recommended_courses[:5]:
|
||||
filtered_course.append({
|
||||
"course_key": course_key,
|
||||
"key": course_key,
|
||||
"title": f"Title for {course_key}",
|
||||
"logo_image_url": "https://www.logo_image_url.com",
|
||||
"marketing_url": "https://www.marketing_url.com",
|
||||
|
||||
@@ -67,13 +67,14 @@ class CourseRecommendationApiView(APIView):
|
||||
if is_control or is_control is None or not course_keys:
|
||||
return self._general_recommendations_response(user_id, is_control, fallback_recommendations)
|
||||
|
||||
recommended_courses = filter_recommended_courses(request.user, course_keys)
|
||||
filtered_courses = filter_recommended_courses(request.user, course_keys, recommendation_count=5)
|
||||
# If no courses are left after filtering already enrolled courses from
|
||||
# the list of amplitude recommendations, show general recommendations
|
||||
# to the user.
|
||||
if not recommended_courses:
|
||||
if not filtered_courses:
|
||||
return self._general_recommendations_response(user_id, is_control, fallback_recommendations)
|
||||
|
||||
recommended_courses = list(map(self._course_data, filtered_courses))
|
||||
self._emit_recommendations_viewed_event(user_id, is_control, recommended_courses)
|
||||
return Response(
|
||||
CourseRecommendationSerializer(
|
||||
@@ -113,3 +114,13 @@ class CourseRecommendationApiView(APIView):
|
||||
).data,
|
||||
status=200,
|
||||
)
|
||||
|
||||
def _course_data(self, course):
|
||||
"""Helper method for personalized recommendation response"""
|
||||
return {
|
||||
"course_key": course.get("key"),
|
||||
"title": course.get("title"),
|
||||
"logo_image_url": course.get("owners")[0]["logo_image_url"] if course.get(
|
||||
"owners") else "",
|
||||
"marketing_url": course.get("marketing_url"),
|
||||
}
|
||||
|
||||
26
lms/djangoapps/learner_recommendations/toggles.py
Normal file
26
lms/djangoapps/learner_recommendations/toggles.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Toggles for learner recommendations.
|
||||
"""
|
||||
from edx_toggles.toggles import WaffleFlag
|
||||
|
||||
# Namespace for learner_recommendations waffle flags.
|
||||
WAFFLE_FLAG_NAMESPACE = 'learner_recommendations'
|
||||
|
||||
|
||||
# Waffle flag to enable course about page recommendations.
|
||||
# .. toggle_name: learner_recommendations.enable_course_about_page_recommendations
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Enable recommendations on course about page
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-01-30
|
||||
# .. toggle_target_removal_date: None
|
||||
# .. toggle_warning: None
|
||||
# .. toggle_tickets: VAN-1259
|
||||
ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS = WaffleFlag(
|
||||
f'{WAFFLE_FLAG_NAMESPACE}.enable_course_about_page_recommendations', __name__
|
||||
)
|
||||
|
||||
|
||||
def enable_course_about_page_recommendations():
|
||||
return ENABLE_COURSE_ABOUT_PAGE_RECOMMENDATIONS.is_enabled()
|
||||
@@ -13,4 +13,7 @@ urlpatterns = [
|
||||
re_path(fr'^algolia/courses/{settings.COURSE_ID_PATTERN}/$',
|
||||
views.AlgoliaCoursesSearchView.as_view(),
|
||||
name='algolia_courses'),
|
||||
re_path(fr'^amplitude/{settings.COURSE_ID_PATTERN}/$',
|
||||
views.AmplitudeRecommendationsView.as_view(),
|
||||
name='amplitude_recommendations'),
|
||||
]
|
||||
|
||||
@@ -55,6 +55,68 @@ def _remove_user_enrolled_course_keys(user, course_keys):
|
||||
return enrollable_course_keys
|
||||
|
||||
|
||||
def _has_country_restrictions(product, user_country):
|
||||
"""
|
||||
Helper method that tell whether the product (course or program) has any country restrictions.
|
||||
A product is restricted for the user if the country in which user is logged in from:
|
||||
- is in the "block list" or
|
||||
- is not in the "allow list" if the "allow list" is not empty. If it is empty, then all locations can access it.
|
||||
Args:
|
||||
product: course/program
|
||||
user_country (string): country the user is logged in from
|
||||
|
||||
Returns:
|
||||
True if the product is restricted in the country and False otherwise
|
||||
"""
|
||||
if not user_country:
|
||||
return False
|
||||
|
||||
allow_list, block_list = [], []
|
||||
location_restriction = product.get("location_restriction", None)
|
||||
if location_restriction:
|
||||
restriction_type = location_restriction.get("restriction_type")
|
||||
countries = location_restriction.get("countries")
|
||||
if restriction_type == "allowlist":
|
||||
allow_list = countries
|
||||
if restriction_type == "blocklist":
|
||||
block_list = countries
|
||||
|
||||
return user_country in block_list or (allow_list and user_country not in allow_list)
|
||||
|
||||
|
||||
def _parse_course_owner_data(owner):
|
||||
"""
|
||||
Helper to parse course owner data.
|
||||
"""
|
||||
return {
|
||||
"key": owner.get("key"),
|
||||
"name": owner.get("name"),
|
||||
"logo_image_url": owner.get("logo_image_url")
|
||||
}
|
||||
|
||||
|
||||
def course_data_for_discovery_card(course_data):
|
||||
"""Helper method to prepare data for prospectus course card"""
|
||||
recommended_course_data = {}
|
||||
active_course_run = [course_run for course_run in course_data.get("course_runs", [])
|
||||
if course_run.get("availability") == "Current"][0]
|
||||
if active_course_run:
|
||||
owners = map(_parse_course_owner_data, course_data.get("owners"))
|
||||
recommended_course_data.update({
|
||||
"uuid": course_data.get("uuid"),
|
||||
"title": course_data.get("title"),
|
||||
"image": course_data.get("image"),
|
||||
"owners": owners,
|
||||
"prospectus_path": f"courses/{course_data.get('url_slug')}",
|
||||
"active_course_run": {
|
||||
"key": active_course_run.get("key"),
|
||||
"type": "Active",
|
||||
"marketing_url": active_course_run.get("marketing_url"),
|
||||
}
|
||||
})
|
||||
return recommended_course_data
|
||||
|
||||
|
||||
def get_algolia_courses_recommendation(course_data):
|
||||
"""
|
||||
Get courses recommendation from Algolia search.
|
||||
@@ -133,37 +195,32 @@ def get_amplitude_course_recommendations(user_id, recommendation_id):
|
||||
return True, False, []
|
||||
|
||||
|
||||
def filter_recommended_courses(user, unfiltered_course_keys, recommendation_count=5):
|
||||
def filter_recommended_courses(
|
||||
user, unfiltered_course_keys, recommendation_count=10, user_country_code=None, request_course=None
|
||||
):
|
||||
"""
|
||||
Returns the filtered course recommendations. The unfiltered course keys
|
||||
pass through the following filters:
|
||||
1. Remove courses that a user is already enrolled in
|
||||
2. <To be added later>
|
||||
1. Remove courses that a user is already enrolled in.
|
||||
2. If user is seeing the recommendations on a course about pages, filter that course out of recommendations.
|
||||
3. Remove the courses which is restricted in user region.
|
||||
|
||||
Returns:
|
||||
filtered_recommended_courses (list): A list of course objects. Each item has
|
||||
the following details for a course:
|
||||
- course key (course_key)
|
||||
- title
|
||||
- partner image url (logo_image_url)
|
||||
- marketing url for the course (marketing_url)
|
||||
filtered_recommended_courses (list): A list of filtered course objects.
|
||||
"""
|
||||
filtered_recommended_courses = []
|
||||
fields = ["title", "owners", "marketing_url"]
|
||||
fields = ["key", "uuid", "title", "owners", "image", "url_slug", "course_runs", "location_restriction"]
|
||||
|
||||
# Remove the course keys a user is already enrolled in
|
||||
enrollable_course_keys = _remove_user_enrolled_course_keys(user, unfiltered_course_keys)[:recommendation_count]
|
||||
enrollable_course_keys = _remove_user_enrolled_course_keys(user, unfiltered_course_keys)
|
||||
# If user is seeing the recommendations on a course about pages, filter that course out of recommendations
|
||||
recommended_course_keys = [course_key for course_key in enrollable_course_keys if course_key != request_course]
|
||||
|
||||
for course_id in enrollable_course_keys:
|
||||
for course_id in recommended_course_keys:
|
||||
if len(filtered_recommended_courses) >= recommendation_count:
|
||||
break
|
||||
course_data = get_course_data(course_id, fields)
|
||||
if course_data:
|
||||
filtered_recommended_courses.append(
|
||||
{
|
||||
"course_key": course_id,
|
||||
"title": course_data["title"],
|
||||
"logo_image_url": course_data["owners"][0]["logo_image_url"],
|
||||
"marketing_url": course_data.get("marketing_url"),
|
||||
}
|
||||
)
|
||||
if course_data and not _has_country_restrictions(course_data, user_country_code):
|
||||
filtered_recommended_courses.append(course_data)
|
||||
|
||||
return filtered_recommended_courses
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
Views for Learner Recommendations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from ipware.ip import get_client_ip
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from edx_rest_framework_extensions.auth.session.authentication import (
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@@ -14,11 +18,20 @@ from openedx.core.djangoapps.catalog.utils import (
|
||||
get_course_data,
|
||||
get_course_run_details
|
||||
)
|
||||
from openedx.core.djangoapps.geoinfo.api import country_code_from_ip
|
||||
from openedx.features.enterprise_support.utils import is_enterprise_learner
|
||||
from lms.djangoapps.learner_recommendations.toggles import enable_course_about_page_recommendations
|
||||
from lms.djangoapps.learner_recommendations.utils import (
|
||||
get_algolia_courses_recommendation
|
||||
get_algolia_courses_recommendation,
|
||||
get_amplitude_course_recommendations,
|
||||
filter_recommended_courses,
|
||||
course_data_for_discovery_card,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlgoliaCoursesSearchView(APIView):
|
||||
"""
|
||||
**Example Request**
|
||||
@@ -46,3 +59,51 @@ class AlgoliaCoursesSearchView(APIView):
|
||||
response = get_algolia_courses_recommendation(course_data)
|
||||
|
||||
return Response({"courses": response.get("hits", []), "count": response.get("nbHits", 0)}, status=200)
|
||||
|
||||
|
||||
class AmplitudeRecommendationsView(APIView):
|
||||
"""
|
||||
**Example Request**
|
||||
|
||||
GET api/learner_recommendations/amplitude/{course_id}/
|
||||
"""
|
||||
|
||||
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, course_id):
|
||||
"""
|
||||
Recommend courses based on amplitude recommendations.
|
||||
Recommend program if user is enrolled-in any other course of program.
|
||||
"""
|
||||
if not enable_course_about_page_recommendations():
|
||||
return Response("Recommendations not found", status=404)
|
||||
|
||||
if is_enterprise_learner(request.user):
|
||||
raise PermissionDenied()
|
||||
|
||||
recommendation_count = 4
|
||||
course_key = course_id
|
||||
split_course_key = course_key.split(":")[-1]
|
||||
split_course_id = split_course_key.split("+")[:2]
|
||||
course_id = f"{split_course_id[0]}+{split_course_id[1]}"
|
||||
|
||||
try:
|
||||
is_control, has_is_control, course_keys = get_amplitude_course_recommendations(
|
||||
request.user.id, settings.COURSE_ABOUT_PAGE_AMPLITUDE_RECOMMENDATION_ID
|
||||
)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
log.info(f"Amplitude API failed due to: {err}")
|
||||
return Response("Recommendations not found", status=404)
|
||||
|
||||
is_control = is_control if has_is_control else None
|
||||
|
||||
ip_address = get_client_ip(request)[0]
|
||||
user_country_code = country_code_from_ip(ip_address).upper()
|
||||
filtered_courses = filter_recommended_courses(
|
||||
request.user, course_keys, user_country_code=user_country_code, request_course=course_id,
|
||||
)
|
||||
recommended_courses = map(course_data_for_discovery_card, filtered_courses)
|
||||
recommended_courses = recommended_courses[:recommendation_count]
|
||||
|
||||
return Response({"is_control": is_control, "courses": recommended_courses}, status=200)
|
||||
|
||||
@@ -4769,6 +4769,7 @@ BRAZE_COURSE_ENROLLMENT_CANVAS_ID = ''
|
||||
AMPLITUDE_URL = ''
|
||||
AMPLITUDE_API_KEY = ''
|
||||
DASHBOARD_AMPLITUDE_RECOMMENDATION_ID = ''
|
||||
COURSE_ABOUT_PAGE_AMPLITUDE_RECOMMENDATION_ID = ''
|
||||
# Keeping this for back compatibility with learner dashboard api
|
||||
GENERAL_RECOMMENDATION = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user