From 9d1f31603e8bea825c6e81404eb59aae3ed8805f Mon Sep 17 00:00:00 2001 From: Attiya Ishaque Date: Tue, 5 Jul 2022 16:23:38 +0500 Subject: [PATCH] feat: [VAN-986] Add amplitude API (#30675) * feat: [VAN-986] Add amplitude API * feat: add suggestion * feat: add spinner Co-authored-by: Zainab Amir --- cms/envs/common.py | 2 + common/djangoapps/student/models.py | 22 ++- lms/djangoapps/learner_dashboard/api/utils.py | 31 ++++ .../learner_dashboard/api/v0/urls.py | 7 +- .../learner_dashboard/api/v0/views.py | 45 ++++++ lms/envs/common.py | 8 + .../RecommendationsPanel.jsx | 138 +++++++++--------- openedx/core/djangoapps/catalog/utils.py | 30 ++++ 8 files changed, 211 insertions(+), 72 deletions(-) create mode 100644 lms/djangoapps/learner_dashboard/api/utils.py diff --git a/cms/envs/common.py b/cms/envs/common.py index ce211a29ea..3f5939e33b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2666,3 +2666,5 @@ COURSE_LIVE_HELP_URL = "https://edx.readthedocs.io/projects/edx-partner-course-s # keys for big blue button live provider COURSE_LIVE_GLOBAL_CREDENTIALS = {} + +PERSONALIZED_RECOMMENDATION_COOKIE_NAME = 'edx-user-personalized-recommendation' diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 03577a45af..e7e8457af8 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -19,7 +19,7 @@ from collections import defaultdict, namedtuple # lint-amnesty, pylint: disable from datetime import date, datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order from functools import total_ordering # lint-amnesty, pylint: disable=wrong-import-order from importlib import import_module # lint-amnesty, pylint: disable=wrong-import-order -from urllib.parse import urlencode, urljoin +from urllib.parse import unquote, urlencode, urljoin import crum from config_models.models import ConfigurationModel @@ -438,6 +438,21 @@ def get_potentially_retired_user_by_username_and_hash(username, hashed_username) return User.objects.get(username__in=locally_hashed_usernames) +def is_personalized_recommendation_for_user(course_id): + """ + Returns the personalized recommendation value from the cookie. + """ + request = crum.get_current_request() + recommended_courses = \ + request.COOKIES.get(settings.PERSONALIZED_RECOMMENDATION_COOKIE_NAME, None) if request else None + + if recommended_courses: + recommended_courses = json.loads(unquote(recommended_courses)) + if course_id in recommended_courses['course_keys']: + return recommended_courses['is_personalized_recommendation'] + return None + + class UserStanding(models.Model): """ This table contains a student's account's status. @@ -1557,6 +1572,11 @@ class CourseEnrollment(models.Model): self.course_id) segment_properties['course_start'] = self.course.start segment_properties['course_pacing'] = self.course.pacing + + is_personalized_recommendation = is_personalized_recommendation_for_user(str(self.course_id)) + if is_personalized_recommendation is not None: + segment_properties['is_personalized_recommendation'] = is_personalized_recommendation + with tracker.get_tracker().context(event_name, context): tracker.emit(event_name, data) segment.track(self.user_id, event_name, segment_properties, traits=segment_traits) diff --git a/lms/djangoapps/learner_dashboard/api/utils.py b/lms/djangoapps/learner_dashboard/api/utils.py new file mode 100644 index 0000000000..41764650f0 --- /dev/null +++ b/lms/djangoapps/learner_dashboard/api/utils.py @@ -0,0 +1,31 @@ +"""API utils""" + +import logging +import requests +from django.conf import settings + +log = logging.getLogger(__name__) + + +def get_personalized_course_recommendations(user_id): + """ get personalize recommendations from Amplitude. """ + headers = { + 'Authorization': f'Api-Key {settings.AMPLITUDE_API_KEY}', + 'Content-Type': 'application/json' + } + params = { + 'user_id': user_id, + 'get_recs': True, + 'rec_id': settings.REC_ID, + } + try: + response = requests.get(settings.AMPLITUDE_URL, params=params, headers=headers) + if response.status_code == 200: + response = response.json() + is_control = response['userData']['recommendations'][0]['is_control'] + course_keys = response['userData']['recommendations'][0]['items'] + return is_control, course_keys + except Exception as ex: # pylint: disable=broad-except + log.exception(f'Cannot get recommendations from Amplitude: {ex}') + + return True, [] diff --git a/lms/djangoapps/learner_dashboard/api/v0/urls.py b/lms/djangoapps/learner_dashboard/api/v0/urls.py index bb178172e9..f2f9789efd 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/urls.py +++ b/lms/djangoapps/learner_dashboard/api/v0/urls.py @@ -4,12 +4,17 @@ Learner Dashboard API v0 URLs. from django.urls import re_path -from lms.djangoapps.learner_dashboard.api.v0.views import Programs, ProgramProgressDetailView +from lms.djangoapps.learner_dashboard.api.v0.views import ( + Programs, + ProgramProgressDetailView, + CourseRecommendationApiView +) UUID_REGEX_PATTERN = r'[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}' app_name = 'v0' urlpatterns = [ + re_path(r'^recommendation/courses/$', CourseRecommendationApiView.as_view(), name='courses'), re_path( fr'^programs/(?P{UUID_REGEX_PATTERN})/$', Programs.as_view(), diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py index 7a26adc201..69a972d829 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.track import segment from openedx.core.djangoapps.programs.utils import ( ProgramProgressMeter, get_certificates, @@ -15,6 +16,8 @@ from openedx.core.djangoapps.programs.utils import ( get_program_and_course_data, get_program_urls ) +from openedx.core.djangoapps.catalog.utils import get_course_data +from lms.djangoapps.learner_dashboard.api.utils import get_personalized_course_recommendations class Programs(APIView): @@ -335,3 +338,45 @@ class ProgramProgressDetailView(APIView): 'credit_pathways': credit_pathways, } ) + + +class CourseRecommendationApiView(APIView): + """ + **Example Request** + + GET api/dashboard/v0/recommendation/courses/ + """ + + authentication_classes = (JwtAuthentication, SessionAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request): + """ Retrieves course recommendations details of a user in a specified course. """ + user_id = request.user.id + is_control, course_keys = get_personalized_course_recommendations(user_id) + + # Emits an event to track student dashboard page visits. + segment.track( + user_id, + 'edx.bi.user.recommendations.viewed', + { + 'is_personalized_recommendation': not is_control, + } + ) + + if is_control: + return Response(status=400) + + recommended_courses = [] + for course_id in course_keys: + course_data = get_course_data(course_id) + if course_data: + recommended_courses.append({ + 'course_key': course_data['key'], + 'title': str(course_data['title']), + 'logo_image_url': course_data['owners'][0]['logo_image_url'], + 'marketing_url': course_data.get('marketing_url') + }) + else: + return Response(status=400) + return Response({'courses': recommended_courses, 'is_personalized_recommendation': not is_control}, status=200) diff --git a/lms/envs/common.py b/lms/envs/common.py index 6a9ee67479..f652500a44 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4711,6 +4711,12 @@ SAVE_FOR_LATER_EMAIL_RATE_LIMIT = '5/h' EDX_BRAZE_API_KEY = None EDX_BRAZE_API_SERVER = None +### SETTINGS FOR AMPLITUDE #### +AMPLITUDE_URL = '' +AMPLITUDE_API_KEY = '' +REC_ID = '' +GENERAL_RECOMMENDATION = {} + ############### Settings for Retirement ##################### # .. setting_name: RETIRED_USERNAME_PREFIX # .. setting_default: retired__user_ @@ -5132,3 +5138,5 @@ ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://127.0.0.1:8000/oaut # keys for big blue button live provider COURSE_LIVE_GLOBAL_CREDENTIALS = {} + +PERSONALIZED_RECOMMENDATION_COOKIE_NAME = 'edx-user-personalized-recommendation' diff --git a/lms/static/js/learner_dashboard/RecommendationsPanel.jsx b/lms/static/js/learner_dashboard/RecommendationsPanel.jsx index 7a029351c4..a8f1739be9 100644 --- a/lms/static/js/learner_dashboard/RecommendationsPanel.jsx +++ b/lms/static/js/learner_dashboard/RecommendationsPanel.jsx @@ -1,96 +1,94 @@ /* global gettext */ import React from 'react'; +import Cookies from 'js-cookie'; class RecommendationsPanel extends React.Component { constructor(props) { super(props); + this.domainInfo = { domain: props.sharedCookieDomain, expires: 365, path: '/' }; + this.cookieName = props.cookieName; this.onCourseSelect = this.onCourseSelect.bind(this); + this.getCourseList = this.getCourseList.bind(this); + this.state = { + isPersonalizedRecommendation: false, + coursesList: [], + }; } onCourseSelect(courseKey) { window.analytics.track('edx.bi.user.recommended.course.click', { course_key: courseKey, - is_personalized_recommendation: false, // TODO: Use state here with default false and update its value from API response. + is_personalized_recommendation: this.state.isPersonalizedRecommendation, + }); + + let recommendedCourses = Cookies.get(this.cookieName); + if (typeof recommendedCourses === 'undefined') { + recommendedCourses = { course_keys: [courseKey] }; + } else { + recommendedCourses = JSON.parse(recommendedCourses); + if (!recommendedCourses.course_keys.includes(courseKey)) { + recommendedCourses.course_keys.push(courseKey); + } + } + recommendedCourses['is_personalized_recommendation'] = this.state.isPersonalizedRecommendation; + Cookies.set(this.cookieName, JSON.stringify(recommendedCourses), this.domainInfo); + }; + + getCourseList = async () => { + const coursesRecommendationData = await fetch(`${this.props.lmsRootUrl}/api/dashboard/v0/recommendation/courses/`) + .then(response => { + if (response.status === 400) { + return this.props.generalRecommendations; + } else { + return response.json(); + } + } + ); + this.setState({ + coursesList: coursesRecommendationData['courses'], + isPersonalizedRecommendation: coursesRecommendationData['is_personalized_recommendation'] }); }; + componentDidMount() { + this.getCourseList(); + }; + + render() { return (
{gettext('Recommendations for you')}
-
-
- course image -
- - - - -
-
- course image -
- + )) : ( +
+
+ {gettext('loading')} +
+
+ )}
+ {this.props.exploreCoursesUrl ? (
- + {gettext('Explore courses')} diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index 1f9040f3e9..ad545b34c0 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -739,3 +739,33 @@ def get_programs_for_organization(organization): Retrieve list of program uuids authored by a given organization """ return cache.get(PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL.format(org_key=organization)) + + +def get_course_data(course_key_str): + """ + Retrieve information about the course with the given course key. + + Arguments: + course_key_str: key for the course about which we are retrieving information. + + Returns: + dict with details about specified course. + """ + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course UUID') + if user: + api_client = get_catalog_api_client(user) + base_api_url = get_catalog_api_base_url() + if course_key_str: + course_cache_key = f'{catalog_integration.CACHE_KEY}.course.{course_key_str}' + data = get_api_data( + catalog_integration, + 'courses', + resource_id=course_key_str, + api_client=api_client, + base_api_url=base_api_url, + cache_key=course_cache_key if catalog_integration.is_cache_enabled else None, + long_term_cache=True, + many=False, + ) + if data: + return data