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:
Nathan Sprenkle
2023-01-09 10:09:13 -05:00
committed by GitHub
parent 3525be2c17
commit df47f9df95
21 changed files with 635 additions and 530 deletions

View File

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

View 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"

View 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"),
]

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

View File

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

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

View 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",
),
]

View 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, []

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, []

View File

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

View File

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