From 5e014651cbf9f579dc4ac85da896df97235c3ea6 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Tue, 31 Jan 2023 16:30:54 +0500 Subject: [PATCH] feat: [VAN-1259] Amplitude course recommendation api for course about page (#31650) --- .../api/v0/tests/test_views.py | 5 +- .../learner_dashboard/api/v0/views.py | 15 ++- .../recommendations/test_views.py | 2 +- .../learner_home/recommendations/views.py | 15 ++- .../learner_recommendations/toggles.py | 26 +++++ .../learner_recommendations/urls.py | 3 + .../learner_recommendations/utils.py | 99 +++++++++++++++---- .../learner_recommendations/views.py | 63 +++++++++++- lms/envs/common.py | 1 + 9 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 lms/djangoapps/learner_recommendations/toggles.py diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py index 966cb7ded0..883407f32f 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -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]], }, ) diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py index e8c35662a9..b1eedf1214 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -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 ) diff --git a/lms/djangoapps/learner_home/recommendations/test_views.py b/lms/djangoapps/learner_home/recommendations/test_views.py index c62d949900..c635cc38cf 100644 --- a/lms/djangoapps/learner_home/recommendations/test_views.py +++ b/lms/djangoapps/learner_home/recommendations/test_views.py @@ -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", diff --git a/lms/djangoapps/learner_home/recommendations/views.py b/lms/djangoapps/learner_home/recommendations/views.py index a5f8c3b667..ef98618063 100644 --- a/lms/djangoapps/learner_home/recommendations/views.py +++ b/lms/djangoapps/learner_home/recommendations/views.py @@ -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"), + } diff --git a/lms/djangoapps/learner_recommendations/toggles.py b/lms/djangoapps/learner_recommendations/toggles.py new file mode 100644 index 0000000000..8570422617 --- /dev/null +++ b/lms/djangoapps/learner_recommendations/toggles.py @@ -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() diff --git a/lms/djangoapps/learner_recommendations/urls.py b/lms/djangoapps/learner_recommendations/urls.py index d4d3b308e7..63d4371d7c 100644 --- a/lms/djangoapps/learner_recommendations/urls.py +++ b/lms/djangoapps/learner_recommendations/urls.py @@ -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'), ] diff --git a/lms/djangoapps/learner_recommendations/utils.py b/lms/djangoapps/learner_recommendations/utils.py index 4601997ca3..684c8050c8 100644 --- a/lms/djangoapps/learner_recommendations/utils.py +++ b/lms/djangoapps/learner_recommendations/utils.py @@ -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. + 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 diff --git a/lms/djangoapps/learner_recommendations/views.py b/lms/djangoapps/learner_recommendations/views.py index 743c12fe61..e7e98ab803 100644 --- a/lms/djangoapps/learner_recommendations/views.py +++ b/lms/djangoapps/learner_recommendations/views.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index b9571f93ff..dd17c36040 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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 = {}