feat: [VAN-986] Add amplitude API (#30675)
* feat: [VAN-986] Add amplitude API * feat: add suggestion * feat: add spinner Co-authored-by: Zainab Amir <zainab.amir@arbisoft.com>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
31
lms/djangoapps/learner_dashboard/api/utils.py
Normal file
31
lms/djangoapps/learner_dashboard/api/utils.py
Normal file
@@ -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, []
|
||||
@@ -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<enterprise_uuid>{UUID_REGEX_PATTERN})/$',
|
||||
Programs.as_view(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-4 panel-background">
|
||||
<div className="recommend-heading mb-4">{gettext('Recommendations for you')}</div>
|
||||
<div className="course-card box-shadow-down-1 bg-white mb-3">
|
||||
<div className="box-shadow-down-1 image-box">
|
||||
<img
|
||||
className="panel-course-img"
|
||||
src="https://source.unsplash.com/lQGJCMY5qcM"
|
||||
alt="course image"
|
||||
/>
|
||||
</div>
|
||||
<div className="course-title pl-3">
|
||||
<a href="#" className="course-link" onClick={() => this.onCourseSelect('add-course-key-1')}>
|
||||
The Chemistry of Life
|
||||
<div className={this.state.coursesList.length ? '' : 'spinner-container'}>
|
||||
{this.state.coursesList.length ? this.state.coursesList.map(course => (
|
||||
<a href={course.marketing_url} className="course-link"
|
||||
onClick={() => this.onCourseSelect(course.course_key)}>
|
||||
<div className="course-card box-shadow-down-1 bg-white mb-3">
|
||||
<div className="box-shadow-down-1 image-box">
|
||||
<img
|
||||
className="panel-course-img"
|
||||
src={course.logo_image_url}
|
||||
alt="course image"
|
||||
/>
|
||||
</div>
|
||||
<div className="course-title pl-3">
|
||||
{course.title}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="course-card box-shadow-down-1 bg-white mb-3">
|
||||
<div className="box-shadow-down-1 image-box">
|
||||
<img
|
||||
className="panel-course-img"
|
||||
src="https://source.unsplash.com/KltoLK6Mk-g"
|
||||
alt="course image"
|
||||
/>
|
||||
</div>
|
||||
<div className="course-title pl-3">
|
||||
<a href="#" className="course-link" onClick={() => this.onCourseSelect('add-course-key-2')}>
|
||||
Drug Discovery & Medicinal Chemistry
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="course-card box-shadow-down-1 bg-white mb-3">
|
||||
<div className="box-shadow-down-1 image-box">
|
||||
<img
|
||||
className="panel-course-img"
|
||||
src="https://source.unsplash.com/_BJVJ4WcV1M"
|
||||
alt="course image"
|
||||
/>
|
||||
</div>
|
||||
<div className="course-title pl-3">
|
||||
<a href="#" className="course-link" onClick={() => this.onCourseSelect('add-course-key-3')}>
|
||||
From Fossil Resources to Biomass: A Chemistry Perspective
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="course-card box-shadow-down-1 bg-white mb-3">
|
||||
<div className="box-shadow-down-1 image-box">
|
||||
<img
|
||||
className="panel-course-img"
|
||||
src="https://source.unsplash.com/NKhckz9B78c"
|
||||
alt="course image"
|
||||
/>
|
||||
</div>
|
||||
<div className="course-title pl-3">
|
||||
<a href="#" className="course-link" onClick={() => this.onCourseSelect('add-course-key-4')}>
|
||||
Digital Biomaterials
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="course-card box-shadow-down-1 bg-white mb-3">
|
||||
<div className="box-shadow-down-1 image-box">
|
||||
<img
|
||||
className="panel-course-img"
|
||||
src="https://source.unsplash.com/x649mR6yBIs"
|
||||
alt="course image"
|
||||
/>
|
||||
</div>
|
||||
<div className="course-title pl-3">
|
||||
<a href="#" className="course-link" onClick={() => this.onCourseSelect('add-course-key-5')}>
|
||||
Basic Steps in Magnetic Resonance
|
||||
</a>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<div role="status" className="spinner">
|
||||
<span className="sr-only">{gettext('loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{this.props.exploreCoursesUrl ? (
|
||||
<div className="d-flex justify-content-center">
|
||||
<a href={this.props.exploreCoursesUrl} className="panel-explore-courses justify-content-center align-items-center">
|
||||
<a href={this.props.exploreCoursesUrl}
|
||||
className="panel-explore-courses justify-content-center align-items-center">
|
||||
{gettext('Explore courses')}
|
||||
<span className="icon fa fa-search search-icon" aria-hidden="true"/>
|
||||
</a>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user