feat: [VAN-1259] Amplitude course recommendation api for course about page (#31650)

This commit is contained in:
Mubbshar Anwar
2023-01-31 16:30:54 +05:00
committed by GitHub
parent 54cd3c562a
commit 5e014651cb
9 changed files with 200 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View 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()

View File

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

View File

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

View File

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

View File

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