refactor: Split learner home experimental / core functionality (#31498)
* refactor: remove old profiling function * refactor: move mocks into separate directory * refactor: move recommendations code into a new dir * docs: docstring consistency and branding updates * docs: add ADR for core versus experimental code
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
Core Versus Experimental Code
|
||||
--------------
|
||||
|
||||
Status
|
||||
======
|
||||
|
||||
Approved
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
One of the goals of the new Learner Home is to provide entry points for experimentation of different features. For example, the frontend offers a separate sidebar widget for showing a user their recommended courses.
|
||||
|
||||
In general, it is expected that these experiments will *NOT* modify or impact the core page functionality, showing a user their current enrollments / entitlements.
|
||||
|
||||
Decisions
|
||||
=========
|
||||
|
||||
Any experiments, functionalities that are not necessarily tied to the core functionality of the page, should not modify the main `init` call that delivers user enrollment / entitlement data but instead exist as separate views / APIs, ideally in separate folders under `learner_home`.
|
||||
|
||||
This is both to avoid code bloat and protect the core functionality of Learner Home against bugs, outages, or regressions from experimental add-ons.
|
||||
|
||||
Consequences
|
||||
============
|
||||
|
||||
By separating experimental / add-on code from the core page functionality, breaks or regressions introduced by experiments are expected to only impact containers / widgets which rely on that experimental code, leaving core functionality relatively stable and reliable.
|
||||
|
||||
While this is expected to increase the number of files / folders to keep track of inside of the `learner_home` app, the separation of files by functionality is expected to decrease cognitive load by making files / views more single-purpose and targeted.
|
||||
|
||||
Alternatives
|
||||
============
|
||||
|
||||
1. Allow experimental code to live in the same files as core functionality. This is not so bad but increases the size and complexity of files and increases cognitive load while developing / debugging.
|
||||
2. Allow experimental code to graft on to core functionality. We want to avoid this as it will necessarily slow the pace of experimentation and increase the need to regression test any experimental changes to avoid breaking core functionality.
|
||||
0
lms/djangoapps/learner_home/mock/__init__.py
Normal file
0
lms/djangoapps/learner_home/mock/__init__.py
Normal file
@@ -10,7 +10,7 @@ from os import path
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.generics import RetrieveAPIView
|
||||
|
||||
LEARNER_HOME_DIR = "/edx/app/edxapp/edx-platform/lms/djangoapps/learner_home"
|
||||
LEARNER_HOME_DIR = "/edx/app/edxapp/edx-platform/lms/djangoapps/learner_home/mock"
|
||||
MOCK_DATA_FILE = "mock_data.json"
|
||||
|
||||
|
||||
9
lms/djangoapps/learner_home/mock/urls.py
Normal file
9
lms/djangoapps/learner_home/mock/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Learner Home mock URL routing configuration"""
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from lms.djangoapps.learner_home.mock import mock_views
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r"^init/?", mock_views.InitializeView.as_view(), name="mock_initialize"),
|
||||
]
|
||||
24
lms/djangoapps/learner_home/recommendations/serializers.py
Normal file
24
lms/djangoapps/learner_home/recommendations/serializers.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Serializers for Course Recommendations
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class RecommendedCourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a recommended course from the recommendation engine"""
|
||||
|
||||
courseKey = serializers.CharField(source="course_key")
|
||||
logoImageUrl = serializers.URLField(source="logo_image_url")
|
||||
marketingUrl = serializers.URLField(source="marketing_url")
|
||||
title = serializers.CharField()
|
||||
|
||||
|
||||
class CourseRecommendationSerializer(serializers.Serializer):
|
||||
"""Recommended courses by the Amplitude"""
|
||||
|
||||
courses = serializers.ListField(
|
||||
child=RecommendedCourseSerializer(), allow_empty=True
|
||||
)
|
||||
isPersonalizedRecommendation = serializers.BooleanField(
|
||||
source="is_personalized_recommendation"
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for serializers for the Learner Home"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from lms.djangoapps.learner_home.recommendations.serializers import (
|
||||
CourseRecommendationSerializer,
|
||||
)
|
||||
from lms.djangoapps.learner_home.test_utils import (
|
||||
random_url,
|
||||
)
|
||||
|
||||
|
||||
class TestCourseRecommendationSerializer(TestCase):
|
||||
"""High-level tests for CourseRecommendationSerializer"""
|
||||
|
||||
@classmethod
|
||||
def mock_recommended_courses(cls, courses_count=2):
|
||||
"""Sample course data"""
|
||||
|
||||
recommended_courses = []
|
||||
|
||||
for _ in range(courses_count):
|
||||
recommended_courses.append(
|
||||
{
|
||||
"course_key": str(uuid4()),
|
||||
"logo_image_url": random_url(),
|
||||
"marketing_url": random_url(),
|
||||
"title": str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
return recommended_courses
|
||||
|
||||
def test_no_recommended_courses(self):
|
||||
"""That that data serializes correctly for empty courses list"""
|
||||
|
||||
recommended_courses = self.mock_recommended_courses(courses_count=0)
|
||||
|
||||
output_data = CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": recommended_courses,
|
||||
"is_personalized_recommendation": False,
|
||||
}
|
||||
).data
|
||||
|
||||
self.assertDictEqual(
|
||||
output_data,
|
||||
{
|
||||
"courses": [],
|
||||
"isPersonalizedRecommendation": False,
|
||||
},
|
||||
)
|
||||
|
||||
def test_happy_path(self):
|
||||
"""Test that data serializes correctly"""
|
||||
|
||||
recommended_courses = self.mock_recommended_courses()
|
||||
|
||||
output_data = CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": recommended_courses,
|
||||
"is_personalized_recommendation": True,
|
||||
}
|
||||
).data
|
||||
|
||||
self.assertDictEqual(
|
||||
output_data,
|
||||
{
|
||||
"courses": [
|
||||
{
|
||||
"courseKey": recommended_courses[0]["course_key"],
|
||||
"logoImageUrl": recommended_courses[0]["logo_image_url"],
|
||||
"marketingUrl": recommended_courses[0]["marketing_url"],
|
||||
"title": recommended_courses[0]["title"],
|
||||
},
|
||||
{
|
||||
"courseKey": recommended_courses[1]["course_key"],
|
||||
"logoImageUrl": recommended_courses[1]["logo_image_url"],
|
||||
"marketingUrl": recommended_courses[1]["marketing_url"],
|
||||
"title": recommended_courses[1]["title"],
|
||||
},
|
||||
],
|
||||
"isPersonalizedRecommendation": True,
|
||||
},
|
||||
)
|
||||
263
lms/djangoapps/learner_home/recommendations/test_views.py
Normal file
263
lms/djangoapps/learner_home/recommendations/test_views.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Tests for Course Recommendations
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
|
||||
from common.djangoapps.student.tests.factories import (
|
||||
CourseEnrollmentFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from lms.djangoapps.learner_home.test_utils import (
|
||||
random_url,
|
||||
)
|
||||
from lms.djangoapps.learner_home.recommendations.waffle import (
|
||||
ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
SharedModuleStoreTestCase,
|
||||
)
|
||||
|
||||
|
||||
class TestCourseRecommendationApiView(SharedModuleStoreTestCase):
|
||||
"""Unit tests for the course recommendations on learner home page."""
|
||||
|
||||
password = "test"
|
||||
url = reverse_lazy("learner_home:courses")
|
||||
|
||||
GENERAL_RECOMMENDATIONS = [
|
||||
{
|
||||
"course_key": "HogwartsX+6.00.1x",
|
||||
"logo_image_url": random_url(),
|
||||
"marketing_url": random_url(),
|
||||
"title": "Defense Against the Dark Arts",
|
||||
},
|
||||
{
|
||||
"course_key": "MonstersX+SC101EN",
|
||||
"logo_image_url": random_url(),
|
||||
"marketing_url": random_url(),
|
||||
"title": "Scaring 101",
|
||||
},
|
||||
]
|
||||
|
||||
SERIALIZED_GENERAL_RECOMMENDATIONS = [
|
||||
{
|
||||
"courseKey": GENERAL_RECOMMENDATIONS[0]["course_key"],
|
||||
"logoImageUrl": GENERAL_RECOMMENDATIONS[0]["logo_image_url"],
|
||||
"marketingUrl": GENERAL_RECOMMENDATIONS[0]["marketing_url"],
|
||||
"title": GENERAL_RECOMMENDATIONS[0]["title"],
|
||||
},
|
||||
{
|
||||
"courseKey": GENERAL_RECOMMENDATIONS[1]["course_key"],
|
||||
"logoImageUrl": GENERAL_RECOMMENDATIONS[1]["logo_image_url"],
|
||||
"marketingUrl": GENERAL_RECOMMENDATIONS[1]["marketing_url"],
|
||||
"title": GENERAL_RECOMMENDATIONS[1]["title"],
|
||||
},
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password=self.password)
|
||||
self.recommended_courses = [
|
||||
"MITx+6.00.1x",
|
||||
"IBM+PY0101EN",
|
||||
"HarvardX+CS50P",
|
||||
"UQx+IELTSx",
|
||||
"HarvardX+CS50x",
|
||||
"Harvard+CS50z",
|
||||
"BabsonX+EPS03x",
|
||||
"TUMx+QPLS2x",
|
||||
"NYUx+FCS.NET.1",
|
||||
"MichinX+101x",
|
||||
]
|
||||
self.course_run_keys = [
|
||||
"course-v1:MITx+6.00.1x+Run_0",
|
||||
"course-v1:IBM+PY0101EN+Run_0",
|
||||
"course-v1:HarvardX+CS50P+Run_0",
|
||||
"course-v1:UQx+IELTSx+Run_0",
|
||||
"course-v1:HarvardX+CS50x+Run_0",
|
||||
"course-v1:Harvard+CS50z+Run_0",
|
||||
"course-v1:BabsonX+EPS03x+Run_0",
|
||||
"course-v1:TUMx+QPLS2x+Run_0",
|
||||
"course-v1:NYUx+FCS.NET.1+Run_0",
|
||||
"course-v1:MichinX+101x+Run_0",
|
||||
]
|
||||
self.course_data = {
|
||||
"course_key": "MITx+6.00.1x",
|
||||
"title": "Introduction to Computer Science and Programming Using Python",
|
||||
"owners": [{"logo_image_url": "https://www.logo_image_url.com"}],
|
||||
"marketing_url": "https://www.marketing_url.com",
|
||||
}
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=False)
|
||||
def test_waffle_flag_off(self):
|
||||
"""
|
||||
Verify API returns 404 if waffle flag is off.
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.data, None)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.recommendations.views.get_personalized_course_recommendations"
|
||||
)
|
||||
def test_no_recommendations_from_amplitude(
|
||||
self, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Verify API returns general recommendations if no course recommendations from amplitude.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [False, []]
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.recommendations.views.get_personalized_course_recommendations",
|
||||
Mock(side_effect=Exception),
|
||||
)
|
||||
def test_amplitude_api_unexpected_error(self):
|
||||
"""
|
||||
Test that if the Amplitude API gives an unexpected error, general recommendations are returned.
|
||||
"""
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.recommendations.views.get_personalized_course_recommendations"
|
||||
)
|
||||
@mock.patch("lms.djangoapps.learner_home.recommendations.views.get_course_data")
|
||||
def test_get_course_recommendations(
|
||||
self, mocked_get_course_data, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Verify API returns course recommendations.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
False,
|
||||
self.recommended_courses,
|
||||
]
|
||||
mocked_get_course_data.return_value = self.course_data
|
||||
expected_recommendations_length = 5
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), True)
|
||||
self.assertEqual(
|
||||
len(response_content.get("courses")), expected_recommendations_length
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.recommendations.views.get_personalized_course_recommendations"
|
||||
)
|
||||
def test_general_recommendations(
|
||||
self, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Test that a user gets general recommendations for the control group.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
True,
|
||||
self.recommended_courses,
|
||||
]
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.recommendations.views.get_personalized_course_recommendations"
|
||||
)
|
||||
@mock.patch("lms.djangoapps.learner_home.recommendations.views.get_course_data")
|
||||
def test_get_enrollable_course_recommendations(
|
||||
self, mocked_get_course_data, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Verify API returns course recommendations for courses in which user is not enrolled.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
False,
|
||||
self.recommended_courses,
|
||||
]
|
||||
mocked_get_course_data.return_value = self.course_data
|
||||
expected_recommendations = 4
|
||||
# enrolling in 6 courses
|
||||
for course_run_key in self.course_run_keys[:6]:
|
||||
CourseEnrollmentFactory(course_id=course_run_key, user=self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), True)
|
||||
self.assertEqual(len(response_content.get("courses")), expected_recommendations)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.recommendations.views.get_personalized_course_recommendations"
|
||||
)
|
||||
@mock.patch("lms.djangoapps.learner_home.recommendations.views.get_course_data")
|
||||
def test_no_enrollable_course(
|
||||
self, mocked_get_course_data, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Test that if after filtering already enrolled courses from Amplitude recommendations
|
||||
we are left with zero personalized recommendations, we return general recommendations.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
False,
|
||||
self.recommended_courses,
|
||||
]
|
||||
mocked_get_course_data.return_value = self.course_data
|
||||
|
||||
# Enrolling in all courses
|
||||
for course_run_key in self.course_run_keys:
|
||||
CourseEnrollmentFactory(course_id=course_run_key, user=self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
13
lms/djangoapps/learner_home/recommendations/urls.py
Normal file
13
lms/djangoapps/learner_home/recommendations/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Learner home URL routing configuration"""
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from lms.djangoapps.learner_home.recommendations import views
|
||||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
r"^courses/$",
|
||||
views.CourseRecommendationApiView.as_view(),
|
||||
name="courses",
|
||||
),
|
||||
]
|
||||
31
lms/djangoapps/learner_home/recommendations/utils.py
Normal file
31
lms/djangoapps/learner_home/recommendations/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,
|
||||
}
|
||||
response = requests.get(settings.AMPLITUDE_URL, params=params, headers=headers)
|
||||
if response.status_code == 200:
|
||||
response = response.json()
|
||||
recommendations = response.get("userData", {}).get("recommendations", [])
|
||||
if recommendations:
|
||||
is_control = recommendations[0].get("is_control")
|
||||
recommended_course_keys = recommendations[0].get("items")
|
||||
return is_control, recommended_course_keys
|
||||
|
||||
return True, []
|
||||
123
lms/djangoapps/learner_home/recommendations/views.py
Normal file
123
lms/djangoapps/learner_home/recommendations/views.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Views for Course Recommendations in Learner Home
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from edx_rest_framework_extensions.auth.session.authentication import (
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
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 lms.djangoapps.learner_home.recommendations.serializers import (
|
||||
CourseRecommendationSerializer,
|
||||
)
|
||||
from lms.djangoapps.learner_home.recommendations.utils import (
|
||||
get_personalized_course_recommendations,
|
||||
)
|
||||
from lms.djangoapps.learner_home.recommendations.waffle import (
|
||||
should_show_learner_home_amplitude_recommendations,
|
||||
)
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_data
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseRecommendationApiView(APIView):
|
||||
"""
|
||||
API to get personalized recommendations from Amplitude.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/learner_home/recommendation/courses/
|
||||
"""
|
||||
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
permission_classes = (IsAuthenticated, NotJwtRestrictedApplication)
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
Retrieves course recommendations details.
|
||||
"""
|
||||
if not should_show_learner_home_amplitude_recommendations():
|
||||
return Response(status=404)
|
||||
|
||||
general_recommendations_response = Response(
|
||||
CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": settings.GENERAL_RECOMMENDATIONS,
|
||||
"is_personalized_recommendation": False,
|
||||
}
|
||||
).data,
|
||||
status=200,
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = request.user.id
|
||||
is_control, course_keys = get_personalized_course_recommendations(user_id)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.warning(f"Cannot get recommendations from Amplitude: {ex}")
|
||||
return general_recommendations_response
|
||||
|
||||
# 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 or not course_keys:
|
||||
return general_recommendations_response
|
||||
|
||||
recommended_courses = []
|
||||
user_enrolled_course_keys = set()
|
||||
fields = ["title", "owners", "marketing_url"]
|
||||
|
||||
course_enrollments = CourseEnrollment.enrollments_for_user(request.user)
|
||||
for course_enrollment in course_enrollments:
|
||||
course_key = f"{course_enrollment.course_id.org}+{course_enrollment.course_id.course}"
|
||||
user_enrolled_course_keys.add(course_key)
|
||||
|
||||
# Pick 5 course keys, excluding the user's already enrolled course(s).
|
||||
enrollable_course_keys = list(
|
||||
set(course_keys).difference(user_enrolled_course_keys)
|
||||
)[:5]
|
||||
for course_id in enrollable_course_keys:
|
||||
course_data = get_course_data(course_id, fields)
|
||||
if course_data:
|
||||
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 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:
|
||||
return general_recommendations_response
|
||||
|
||||
return Response(
|
||||
CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": recommended_courses,
|
||||
"is_personalized_recommendation": not is_control,
|
||||
}
|
||||
).data,
|
||||
status=200,
|
||||
)
|
||||
26
lms/djangoapps/learner_home/recommendations/waffle.py
Normal file
26
lms/djangoapps/learner_home/recommendations/waffle.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Configuration of recommendation feature for Learner Home.
|
||||
"""
|
||||
|
||||
from edx_toggles.toggles import WaffleFlag
|
||||
|
||||
# Namespace for Learner Home MFE waffle flags.
|
||||
WAFFLE_FLAG_NAMESPACE = "learner_home_mfe"
|
||||
|
||||
# Waffle flag to enable to recommendation panel on learner home mfe
|
||||
# .. toggle_name: learner_home_mfe.enable_learner_home_amplitude_recommendations
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Waffle flag to enable to recommendation panel on learner home mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2022-10-28
|
||||
# .. toggle_target_removal_date: None
|
||||
# .. toggle_warning: None
|
||||
# .. toggle_tickets: VAN-1138
|
||||
ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS = WaffleFlag(
|
||||
f"{WAFFLE_FLAG_NAMESPACE}.enable_learner_home_amplitude_recommendations", __name__
|
||||
)
|
||||
|
||||
|
||||
def should_show_learner_home_amplitude_recommendations():
|
||||
return ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS.is_enabled()
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Serializers for the Learner Dashboard
|
||||
Serializers for Learner Home
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from urllib.parse import urljoin
|
||||
|
||||
@@ -538,26 +539,6 @@ class UnfulfilledEntitlementSerializer(serializers.Serializer):
|
||||
).data
|
||||
|
||||
|
||||
class RecommendedCourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a recommended course from the recommendation engine"""
|
||||
|
||||
courseKey = serializers.CharField(source="course_key")
|
||||
logoImageUrl = serializers.URLField(source="logo_image_url")
|
||||
marketingUrl = serializers.URLField(source="marketing_url")
|
||||
title = serializers.CharField()
|
||||
|
||||
|
||||
class CourseRecommendationSerializer(serializers.Serializer):
|
||||
"""Recommended courses by the Amplitude"""
|
||||
|
||||
courses = serializers.ListField(
|
||||
child=RecommendedCourseSerializer(), allow_empty=True
|
||||
)
|
||||
isPersonalizedRecommendation = serializers.BooleanField(
|
||||
source="is_personalized_recommendation"
|
||||
)
|
||||
|
||||
|
||||
class SuggestedCourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a suggested course from recommendation engine"""
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests for serializers for the Learner Dashboard"""
|
||||
"""
|
||||
Tests for serializers for the Learner Home
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from itertools import product
|
||||
@@ -30,7 +32,6 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import (
|
||||
from lms.djangoapps.learner_home.serializers import (
|
||||
CertificateSerializer,
|
||||
CourseProviderSerializer,
|
||||
CourseRecommendationSerializer,
|
||||
CourseRunSerializer,
|
||||
CourseSerializer,
|
||||
CreditSerializer,
|
||||
@@ -1030,81 +1031,6 @@ class TestUnfulfilledEntitlementSerializer(LearnerDashboardBaseTest):
|
||||
assert expected_keys == actual_keys
|
||||
|
||||
|
||||
class TestCourseRecommendationSerializer(TestCase):
|
||||
"""High-level tests for CourseRecommendationSerializer"""
|
||||
|
||||
@classmethod
|
||||
def mock_recommended_courses(cls, courses_count=2):
|
||||
"""Sample course data"""
|
||||
|
||||
recommended_courses = []
|
||||
|
||||
for _ in range(courses_count):
|
||||
recommended_courses.append(
|
||||
{
|
||||
"course_key": str(uuid4()),
|
||||
"logo_image_url": random_url(),
|
||||
"marketing_url": random_url(),
|
||||
"title": str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
return recommended_courses
|
||||
|
||||
def test_no_recommended_courses(self):
|
||||
"""That that data serializes correctly for empty courses list"""
|
||||
|
||||
recommended_courses = self.mock_recommended_courses(courses_count=0)
|
||||
|
||||
output_data = CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": recommended_courses,
|
||||
"is_personalized_recommendation": False,
|
||||
}
|
||||
).data
|
||||
|
||||
self.assertDictEqual(
|
||||
output_data,
|
||||
{
|
||||
"courses": [],
|
||||
"isPersonalizedRecommendation": False,
|
||||
},
|
||||
)
|
||||
|
||||
def test_happy_path(self):
|
||||
"""Test that data serializes correctly"""
|
||||
|
||||
recommended_courses = self.mock_recommended_courses()
|
||||
|
||||
output_data = CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": recommended_courses,
|
||||
"is_personalized_recommendation": True,
|
||||
}
|
||||
).data
|
||||
|
||||
self.assertDictEqual(
|
||||
output_data,
|
||||
{
|
||||
"courses": [
|
||||
{
|
||||
"courseKey": recommended_courses[0]["course_key"],
|
||||
"logoImageUrl": recommended_courses[0]["logo_image_url"],
|
||||
"marketingUrl": recommended_courses[0]["marketing_url"],
|
||||
"title": recommended_courses[0]["title"],
|
||||
},
|
||||
{
|
||||
"courseKey": recommended_courses[1]["course_key"],
|
||||
"logoImageUrl": recommended_courses[1]["logo_image_url"],
|
||||
"marketingUrl": recommended_courses[1]["marketing_url"],
|
||||
"title": recommended_courses[1]["title"],
|
||||
},
|
||||
],
|
||||
"isPersonalizedRecommendation": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestSuggestedCourseSerializer(TestCase):
|
||||
"""High-level tests for SuggestedCourseSerializer"""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Various utilities used for testing/test data.
|
||||
Various utilities used for creating test data
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from random import choice, getrandbits, randint
|
||||
from time import time
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""Test for learner views and related functions"""
|
||||
"""
|
||||
Test for Learner Home views and related functions
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock, patch
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.test import TestCase, override_settings
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -42,9 +42,6 @@ from lms.djangoapps.learner_home.views import (
|
||||
get_social_share_settings,
|
||||
get_course_share_urls,
|
||||
)
|
||||
from lms.djangoapps.learner_home.waffle import (
|
||||
ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS,
|
||||
)
|
||||
from openedx.core.djangoapps.catalog.tests.factories import (
|
||||
CourseFactory as CatalogCourseFactory,
|
||||
CourseRunFactory as CatalogCourseRunFactory,
|
||||
@@ -863,242 +860,3 @@ class TestDashboardMasquerade(BaseTestDashboardView):
|
||||
# username has priority in the lookup
|
||||
assert response.status_code == 200
|
||||
assert self.get_first_course_id(response) == str(user_3_enrollment.course_id)
|
||||
|
||||
|
||||
class TestCourseRecommendationApiView(SharedModuleStoreTestCase):
|
||||
"""Unit tests for the course recommendations on learner home page."""
|
||||
|
||||
password = "test"
|
||||
url = reverse_lazy("learner_home:courses")
|
||||
|
||||
GENERAL_RECOMMENDATIONS = [
|
||||
{
|
||||
"course_key": "HogwartsX+6.00.1x",
|
||||
"logo_image_url": random_url(),
|
||||
"marketing_url": random_url(),
|
||||
"title": "Defense Against the Dark Arts",
|
||||
},
|
||||
{
|
||||
"course_key": "MonstersX+SC101EN",
|
||||
"logo_image_url": random_url(),
|
||||
"marketing_url": random_url(),
|
||||
"title": "Scaring 101",
|
||||
},
|
||||
]
|
||||
|
||||
SERIALIZED_GENERAL_RECOMMENDATIONS = [
|
||||
{
|
||||
"courseKey": GENERAL_RECOMMENDATIONS[0]["course_key"],
|
||||
"logoImageUrl": GENERAL_RECOMMENDATIONS[0]["logo_image_url"],
|
||||
"marketingUrl": GENERAL_RECOMMENDATIONS[0]["marketing_url"],
|
||||
"title": GENERAL_RECOMMENDATIONS[0]["title"],
|
||||
},
|
||||
{
|
||||
"courseKey": GENERAL_RECOMMENDATIONS[1]["course_key"],
|
||||
"logoImageUrl": GENERAL_RECOMMENDATIONS[1]["logo_image_url"],
|
||||
"marketingUrl": GENERAL_RECOMMENDATIONS[1]["marketing_url"],
|
||||
"title": GENERAL_RECOMMENDATIONS[1]["title"],
|
||||
},
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password=self.password)
|
||||
self.recommended_courses = [
|
||||
"MITx+6.00.1x",
|
||||
"IBM+PY0101EN",
|
||||
"HarvardX+CS50P",
|
||||
"UQx+IELTSx",
|
||||
"HarvardX+CS50x",
|
||||
"Harvard+CS50z",
|
||||
"BabsonX+EPS03x",
|
||||
"TUMx+QPLS2x",
|
||||
"NYUx+FCS.NET.1",
|
||||
"MichinX+101x",
|
||||
]
|
||||
self.course_run_keys = [
|
||||
"course-v1:MITx+6.00.1x+Run_0",
|
||||
"course-v1:IBM+PY0101EN+Run_0",
|
||||
"course-v1:HarvardX+CS50P+Run_0",
|
||||
"course-v1:UQx+IELTSx+Run_0",
|
||||
"course-v1:HarvardX+CS50x+Run_0",
|
||||
"course-v1:Harvard+CS50z+Run_0",
|
||||
"course-v1:BabsonX+EPS03x+Run_0",
|
||||
"course-v1:TUMx+QPLS2x+Run_0",
|
||||
"course-v1:NYUx+FCS.NET.1+Run_0",
|
||||
"course-v1:MichinX+101x+Run_0",
|
||||
]
|
||||
self.course_data = {
|
||||
"course_key": "MITx+6.00.1x",
|
||||
"title": "Introduction to Computer Science and Programming Using Python",
|
||||
"owners": [{"logo_image_url": "https://www.logo_image_url.com"}],
|
||||
"marketing_url": "https://www.marketing_url.com",
|
||||
}
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=False)
|
||||
def test_waffle_flag_off(self):
|
||||
"""
|
||||
Verify API returns 404 if waffle flag is off.
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.data, None)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.views.get_personalized_course_recommendations"
|
||||
)
|
||||
def test_no_recommendations_from_amplitude(
|
||||
self, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Verify API returns general recommendations if no course recommendations from amplitude.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [False, []]
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.views.get_personalized_course_recommendations",
|
||||
Mock(side_effect=Exception),
|
||||
)
|
||||
def test_amplitude_api_unexpected_error(self):
|
||||
"""
|
||||
Test that if the Amplitude API gives an unexpected error, general recommendations are returned.
|
||||
"""
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.views.get_personalized_course_recommendations"
|
||||
)
|
||||
@mock.patch("lms.djangoapps.learner_home.views.get_course_data")
|
||||
def test_get_course_recommendations(
|
||||
self, mocked_get_course_data, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Verify API returns course recommendations.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
False,
|
||||
self.recommended_courses,
|
||||
]
|
||||
mocked_get_course_data.return_value = self.course_data
|
||||
expected_recommendations_length = 5
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), True)
|
||||
self.assertEqual(
|
||||
len(response_content.get("courses")), expected_recommendations_length
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.views.get_personalized_course_recommendations"
|
||||
)
|
||||
def test_general_recommendations(
|
||||
self, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Test that a user gets general recommendations for the control group.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
True,
|
||||
self.recommended_courses,
|
||||
]
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.views.get_personalized_course_recommendations"
|
||||
)
|
||||
@mock.patch("lms.djangoapps.learner_home.views.get_course_data")
|
||||
def test_get_enrollable_course_recommendations(
|
||||
self, mocked_get_course_data, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Verify API returns course recommendations for courses in which user is not enrolled.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
False,
|
||||
self.recommended_courses,
|
||||
]
|
||||
mocked_get_course_data.return_value = self.course_data
|
||||
expected_recommendations = 4
|
||||
# enrolling in 6 courses
|
||||
for course_run_key in self.course_run_keys[:6]:
|
||||
CourseEnrollmentFactory(course_id=course_run_key, user=self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), True)
|
||||
self.assertEqual(len(response_content.get("courses")), expected_recommendations)
|
||||
|
||||
@override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True)
|
||||
@mock.patch("django.conf.settings.GENERAL_RECOMMENDATIONS", GENERAL_RECOMMENDATIONS)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.views.get_personalized_course_recommendations"
|
||||
)
|
||||
@mock.patch("lms.djangoapps.learner_home.views.get_course_data")
|
||||
def test_no_enrollable_course(
|
||||
self, mocked_get_course_data, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Test that if after filtering already enrolled courses from Amplitude recommendations
|
||||
we are left with zero personalized recommendations, we return general recommendations.
|
||||
"""
|
||||
mocked_get_personalized_course_recommendations.return_value = [
|
||||
False,
|
||||
self.recommended_courses,
|
||||
]
|
||||
mocked_get_course_data.return_value = self.course_data
|
||||
|
||||
# Enrolling in all courses
|
||||
for course_run_key in self.course_run_keys:
|
||||
CourseEnrollmentFactory(course_id=course_run_key, user=self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content.get("isPersonalizedRecommendation"), False)
|
||||
self.assertEqual(
|
||||
response_content.get("courses"),
|
||||
self.SERIALIZED_GENERAL_RECOMMENDATIONS,
|
||||
)
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"""Learner home URL routing configuration"""
|
||||
"""
|
||||
Learner Home URL routing configuration
|
||||
"""
|
||||
|
||||
from django.urls import re_path
|
||||
from django.urls import include, re_path
|
||||
|
||||
from lms.djangoapps.learner_home import mock_views, views
|
||||
from lms.djangoapps.learner_home import views
|
||||
|
||||
app_name = "learner_home"
|
||||
|
||||
# Learner Dashboard Routing
|
||||
urlpatterns = [
|
||||
re_path(r"^init/?", views.InitializeView.as_view(), name="initialize"),
|
||||
re_path(r"^mock/", include("lms.djangoapps.learner_home.mock.urls")),
|
||||
re_path(
|
||||
r"^mock/init/?", mock_views.InitializeView.as_view(), name="mock_initialize"
|
||||
),
|
||||
re_path(
|
||||
r"^recommendation/courses/$",
|
||||
views.CourseRecommendationApiView.as_view(),
|
||||
name="courses",
|
||||
r"^recommendation/", include("lms.djangoapps.learner_home.recommendations.urls")
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""API utils"""
|
||||
"""
|
||||
Additional utilities for Learner Home
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from time import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import MultipleObjectsReturned
|
||||
from rest_framework.exceptions import PermissionDenied, NotFound
|
||||
@@ -17,31 +16,6 @@ log = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def exec_time_logged(func):
|
||||
"""Wrap the function and return result and execution time"""
|
||||
|
||||
def wrap_func(*args, **kwargs):
|
||||
# Time the function operation
|
||||
t1 = time()
|
||||
result = func(*args, **kwargs)
|
||||
t2 = time()
|
||||
|
||||
# Display lists / sets as their lengths instead of actual items
|
||||
debug_args = []
|
||||
for arg in args:
|
||||
if isinstance(arg, (list, set)):
|
||||
debug_args.append(f"<list: (len {len(arg)})>")
|
||||
else:
|
||||
debug_args.append(arg)
|
||||
|
||||
# Log the output
|
||||
log.info(f"{func.__name__!r} args:{debug_args} completed in {(t2-t1):.4f}s")
|
||||
|
||||
return result
|
||||
|
||||
return wrap_func
|
||||
|
||||
|
||||
def get_masquerade_user(request):
|
||||
"""
|
||||
Determine if the user is masquerading
|
||||
@@ -80,26 +54,3 @@ def get_masquerade_user(request):
|
||||
)
|
||||
log.info(success_msg)
|
||||
return masquerade_user
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
response = requests.get(settings.AMPLITUDE_URL, params=params, headers=headers)
|
||||
if response.status_code == 200:
|
||||
response = response.json()
|
||||
recommendations = response.get("userData", {}).get("recommendations", [])
|
||||
if recommendations:
|
||||
is_control = recommendations[0].get("is_control")
|
||||
recommended_course_keys = recommendations[0].get("items")
|
||||
return is_control, recommended_course_keys
|
||||
|
||||
return True, []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Views for the learner dashboard.
|
||||
Views for Learner Home
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
@@ -26,7 +27,6 @@ from common.djangoapps.student.helpers import (
|
||||
cert_info,
|
||||
user_has_passing_grade_in_course,
|
||||
)
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.views.dashboard import (
|
||||
complete_course_mode_info,
|
||||
credit_statuses,
|
||||
@@ -34,7 +34,6 @@ from common.djangoapps.student.views.dashboard import (
|
||||
get_filtered_course_entitlements,
|
||||
get_org_black_and_whitelist_for_site,
|
||||
)
|
||||
from common.djangoapps.track import segment
|
||||
from common.djangoapps.util.course import (
|
||||
get_encoded_course_sharing_utm_params,
|
||||
get_link_for_about_page,
|
||||
@@ -48,17 +47,11 @@ from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user
|
||||
from lms.djangoapps.courseware.access_utils import check_course_open_for_learner
|
||||
from lms.djangoapps.learner_home.serializers import (
|
||||
CourseRecommendationSerializer,
|
||||
LearnerDashboardSerializer,
|
||||
)
|
||||
from lms.djangoapps.learner_home.utils import (
|
||||
get_masquerade_user,
|
||||
get_personalized_course_recommendations,
|
||||
)
|
||||
from lms.djangoapps.learner_home.waffle import (
|
||||
should_show_learner_home_amplitude_recommendations,
|
||||
)
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_data
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
@@ -581,96 +574,3 @@ class InitializeView(APIView): # pylint: disable=unused-argument
|
||||
response_data = serialize_learner_home_data(learner_dash_data, context)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
class CourseRecommendationApiView(APIView):
|
||||
"""
|
||||
API to get personalized recommendations from Amplitude.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/learner_home/recommendation/courses/
|
||||
"""
|
||||
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
permission_classes = (IsAuthenticated, NotJwtRestrictedApplication)
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
Retrieves course recommendations details.
|
||||
"""
|
||||
if not should_show_learner_home_amplitude_recommendations():
|
||||
return Response(status=404)
|
||||
|
||||
general_recommendations_response = Response(
|
||||
CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": settings.GENERAL_RECOMMENDATIONS,
|
||||
"is_personalized_recommendation": False,
|
||||
}
|
||||
).data,
|
||||
status=200,
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = request.user.id
|
||||
is_control, course_keys = get_personalized_course_recommendations(user_id)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.warning(f"Cannot get recommendations from Amplitude: {ex}")
|
||||
return general_recommendations_response
|
||||
|
||||
# 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 or not course_keys:
|
||||
return general_recommendations_response
|
||||
|
||||
recommended_courses = []
|
||||
user_enrolled_course_keys = set()
|
||||
fields = ["title", "owners", "marketing_url"]
|
||||
|
||||
course_enrollments = CourseEnrollment.enrollments_for_user(request.user)
|
||||
for course_enrollment in course_enrollments:
|
||||
course_key = f"{course_enrollment.course_id.org}+{course_enrollment.course_id.course}"
|
||||
user_enrolled_course_keys.add(course_key)
|
||||
|
||||
# Pick 5 course keys, excluding the user's already enrolled course(s).
|
||||
enrollable_course_keys = list(
|
||||
set(course_keys).difference(user_enrolled_course_keys)
|
||||
)[:5]
|
||||
for course_id in enrollable_course_keys:
|
||||
course_data = get_course_data(course_id, fields)
|
||||
if course_data:
|
||||
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 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:
|
||||
return general_recommendations_response
|
||||
|
||||
return Response(
|
||||
CourseRecommendationSerializer(
|
||||
{
|
||||
"courses": recommended_courses,
|
||||
"is_personalized_recommendation": not is_control,
|
||||
}
|
||||
).data,
|
||||
status=200,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""
|
||||
This module contains various configuration settings via
|
||||
waffle switches for the teams app.
|
||||
Configuration for features of Learner Home
|
||||
"""
|
||||
|
||||
from edx_toggles.toggles import WaffleFlag
|
||||
@@ -27,22 +26,3 @@ def should_redirect_to_learner_home_mfe():
|
||||
return configuration_helpers.get_value(
|
||||
"ENABLE_LEARNER_HOME_MFE", ENABLE_LEARNER_HOME_MFE.is_enabled()
|
||||
)
|
||||
|
||||
|
||||
# Waffle flag to enable to recommendation panel on learner home mfe
|
||||
# .. toggle_name: learner_home_mfe.enable_learner_home_amplitude_recommendations
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Waffle flag to enable to recommendation panel on learner home mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2022-10-28
|
||||
# .. toggle_target_removal_date: None
|
||||
# .. toggle_warning: None
|
||||
# .. toggle_tickets: VAN-1138
|
||||
ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS = WaffleFlag(
|
||||
f"{WAFFLE_FLAG_NAMESPACE}.enable_learner_home_amplitude_recommendations", __name__
|
||||
)
|
||||
|
||||
|
||||
def should_show_learner_home_amplitude_recommendations():
|
||||
return ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS.is_enabled()
|
||||
|
||||
Reference in New Issue
Block a user