refactor: learner home cleanup (#31240)
* refactor: remove dev logging * style: run black * refactor: move masquerade check into utils * style: remove unnecessary assignment * style: update dependency orderings * refactor: add function tracing for perf testing * refactor: move grade data fetching out of serializer This allows us to separately profile the collection of grade data * fix: add missing requires_context metadata * refactor: split out serialization for profiling
This commit is contained in:
@@ -519,8 +519,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem
|
||||
if not UserProfile.objects.filter(user=user).exists():
|
||||
return redirect(reverse('account_settings'))
|
||||
|
||||
enable_learner_home_mfe = should_redirect_to_learner_home_mfe()
|
||||
if enable_learner_home_mfe:
|
||||
if should_redirect_to_learner_home_mfe():
|
||||
return redirect(settings.LEARNER_HOME_MICROFRONTEND_URL)
|
||||
|
||||
platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
|
||||
|
||||
@@ -10,7 +10,6 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.student.helpers import user_has_passing_grade_in_course
|
||||
from openedx.features.course_experience import course_home_url
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
@@ -63,6 +62,8 @@ class CourseProviderSerializer(serializers.Serializer):
|
||||
class CourseSerializer(serializers.Serializer):
|
||||
"""Course header information, derived from a CourseOverview"""
|
||||
|
||||
requires_context = True
|
||||
|
||||
bannerImgSrc = serializers.URLField(source="image_urls.small")
|
||||
courseName = serializers.CharField(source="display_name_with_default")
|
||||
courseNumber = serializers.CharField(source="display_number_with_default")
|
||||
@@ -139,6 +140,8 @@ class CoursewareAccessSerializer(serializers.Serializer):
|
||||
Mirrors logic in "show_courseware_links_for" from old dashboard.py
|
||||
"""
|
||||
|
||||
requires_context = True
|
||||
|
||||
hasUnmetPrerequisites = serializers.SerializerMethodField()
|
||||
isTooEarly = serializers.SerializerMethodField()
|
||||
isStaff = serializers.SerializerMethodField()
|
||||
@@ -182,6 +185,8 @@ class EnrollmentSerializer(serializers.Serializer):
|
||||
show email settings.
|
||||
"""
|
||||
|
||||
requires_context = True
|
||||
|
||||
accessExpirationDate = serializers.SerializerMethodField()
|
||||
isAudit = serializers.SerializerMethodField()
|
||||
hasStarted = serializers.SerializerMethodField()
|
||||
@@ -238,15 +243,19 @@ class EnrollmentSerializer(serializers.Serializer):
|
||||
class GradeDataSerializer(serializers.Serializer):
|
||||
"""Info about grades for this enrollment"""
|
||||
|
||||
requires_context = True
|
||||
|
||||
isPassing = serializers.SerializerMethodField()
|
||||
|
||||
def get_isPassing(self, enrollment):
|
||||
return user_has_passing_grade_in_course(enrollment)
|
||||
return self.context.get("grade_statuses", {}).get(enrollment.course_id, False)
|
||||
|
||||
|
||||
class CertificateSerializer(serializers.Serializer):
|
||||
"""Certificate availability info"""
|
||||
|
||||
requires_context = True
|
||||
|
||||
availableDate = serializers.SerializerMethodField()
|
||||
isRestricted = serializers.SerializerMethodField()
|
||||
isEarned = serializers.SerializerMethodField()
|
||||
|
||||
@@ -6,10 +6,10 @@ from random import randint
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase
|
||||
import ddt
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
|
||||
@@ -164,11 +164,7 @@ class TestCourseSerializer(LearnerDashboardBaseTest):
|
||||
"""Tests for the CourseSerializer"""
|
||||
|
||||
def create_test_context(self, course_id):
|
||||
return {
|
||||
"course_share_urls": {
|
||||
course_id: random_url()
|
||||
}
|
||||
}
|
||||
return {"course_share_urls": {course_id: random_url()}}
|
||||
|
||||
def test_happy_path(self):
|
||||
test_enrollment = self.create_test_enrollment()
|
||||
@@ -182,7 +178,7 @@ class TestCourseSerializer(LearnerDashboardBaseTest):
|
||||
"bannerImgSrc": test_enrollment.course_overview.banner_image_url,
|
||||
"courseName": test_enrollment.course_overview.display_name_with_default,
|
||||
"courseNumber": test_enrollment.course_overview.display_number_with_default,
|
||||
"socialShareUrl": test_context['course_share_urls'][course_id]
|
||||
"socialShareUrl": test_context["course_share_urls"][course_id],
|
||||
}
|
||||
|
||||
|
||||
@@ -430,17 +426,18 @@ class TestEnrollmentSerializer(LearnerDashboardBaseTest):
|
||||
class TestGradeDataSerializer(LearnerDashboardBaseTest):
|
||||
"""Tests for the GradeDataSerializer"""
|
||||
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_home.serializers.user_has_passing_grade_in_course"
|
||||
)
|
||||
def create_test_context(self, course, is_passing):
|
||||
"""Get a test context object"""
|
||||
return {"grade_statuses": {course.id: is_passing}}
|
||||
|
||||
@ddt.data(True, False, None)
|
||||
def test_happy_path(self, is_passing, mock_get_grade_data):
|
||||
def test_happy_path(self, is_passing):
|
||||
# Given a course where I am/not passing
|
||||
input_data = self.create_test_enrollment()
|
||||
mock_get_grade_data.return_value = is_passing
|
||||
input_context = self.create_test_context(input_data.course, is_passing)
|
||||
|
||||
# When I serialize grade data
|
||||
output_data = GradeDataSerializer(input_data).data
|
||||
output_data = GradeDataSerializer(input_data, context=input_context).data
|
||||
|
||||
# Then I get the correct data shape out
|
||||
self.assertDictEqual(
|
||||
@@ -907,9 +904,10 @@ class TestUnfulfilledEntitlementSerializer(LearnerDashboardBaseTest):
|
||||
output_data["enrollment"]
|
||||
== UnfulfilledEntitlementSerializer.STATIC_ENTITLEMENT_ENROLLMENT_DATA
|
||||
)
|
||||
assert output_data["course"] == CourseSerializer(
|
||||
pseudo_session_course_overviews.popitem()[1]
|
||||
).data
|
||||
assert (
|
||||
output_data["course"]
|
||||
== CourseSerializer(pseudo_session_course_overviews.popitem()[1]).data
|
||||
)
|
||||
assert output_data["courseProvider"] is not None
|
||||
assert output_data["programs"] == {"relatedPrograms": []}
|
||||
|
||||
@@ -1078,7 +1076,7 @@ class TestEnterpriseDashboardSerializer(TestCase):
|
||||
|
||||
|
||||
class TestSocialMediaSettingsSiteSerializer(TestCase):
|
||||
""" Tests for the SocialMediaSiteSettingsSerializer """
|
||||
"""Tests for the SocialMediaSiteSettingsSerializer"""
|
||||
|
||||
@classmethod
|
||||
def generate_test_social_media_settings(cls):
|
||||
@@ -1103,13 +1101,13 @@ class TestSocialMediaSettingsSiteSerializer(TestCase):
|
||||
|
||||
|
||||
class TestSocialShareSettingsSerializer(TestCase):
|
||||
""" Tests for the SocialShareSettingsSerializer """
|
||||
"""Tests for the SocialShareSettingsSerializer"""
|
||||
|
||||
@classmethod
|
||||
def generate_test_social_share_settings(cls):
|
||||
return {
|
||||
"twitter": TestSocialMediaSettingsSiteSerializer.generate_test_social_media_settings(),
|
||||
"facebook": TestSocialMediaSettingsSiteSerializer.generate_test_social_media_settings()
|
||||
"facebook": TestSocialMediaSettingsSiteSerializer.generate_test_social_media_settings(),
|
||||
}
|
||||
|
||||
def test_structure(self):
|
||||
@@ -1118,10 +1116,7 @@ class TestSocialShareSettingsSerializer(TestCase):
|
||||
|
||||
output_data = SocialShareSettingsSerializer(input_data).data
|
||||
|
||||
expected_keys = [
|
||||
"twitter",
|
||||
"facebook"
|
||||
]
|
||||
expected_keys = ["twitter", "facebook"]
|
||||
self.assertEqual(output_data.keys(), set(expected_keys))
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ from common.djangoapps.student.tests.factories import (
|
||||
)
|
||||
from common.djangoapps.util.course import get_encoded_course_sharing_utm_params
|
||||
from lms.djangoapps.bulk_email.models import Optout
|
||||
from lms.djangoapps.learner_home.test_utils import create_test_enrollment, random_string
|
||||
from lms.djangoapps.learner_home.test_utils import (
|
||||
create_test_enrollment,
|
||||
random_string,
|
||||
random_url,
|
||||
)
|
||||
from lms.djangoapps.learner_home.views import (
|
||||
get_course_overviews_for_pseudo_sessions,
|
||||
get_course_programs,
|
||||
@@ -38,9 +42,11 @@ from lms.djangoapps.learner_home.views import (
|
||||
get_social_share_settings,
|
||||
get_course_share_urls,
|
||||
)
|
||||
from lms.djangoapps.learner_home.test_serializers import random_url
|
||||
from lms.djangoapps.learner_home.waffle import ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS
|
||||
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,
|
||||
ProgramFactory,
|
||||
)
|
||||
@@ -48,9 +54,6 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import (
|
||||
CourseOverviewFactory,
|
||||
)
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangoapps.catalog.tests.factories import (
|
||||
CourseFactory as CatalogCourseFactory,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
TEST_DATA_SPLIT_MODULESTORE,
|
||||
SharedModuleStoreTestCase,
|
||||
@@ -425,7 +428,7 @@ class TestGetEnterpriseCustomer(TestCase):
|
||||
|
||||
|
||||
class TestGetSocialShareSettings(TestCase):
|
||||
""" Tests for get_social_share_settings """
|
||||
"""Tests for get_social_share_settings"""
|
||||
|
||||
def test_get_social_share_settings(self):
|
||||
share_settings = {
|
||||
@@ -443,16 +446,16 @@ class TestGetSocialShareSettings(TestCase):
|
||||
"facebook": {
|
||||
"is_enabled": True,
|
||||
"brand": share_settings["FACEBOOK_BRAND"],
|
||||
"utm_params": utm_sources.get("facebook")
|
||||
"utm_params": utm_sources.get("facebook"),
|
||||
},
|
||||
"twitter": {
|
||||
"is_enabled": True,
|
||||
"brand": share_settings["TWITTER_BRAND"],
|
||||
"utm_params": utm_sources.get("twitter")
|
||||
}
|
||||
"utm_params": utm_sources.get("twitter"),
|
||||
},
|
||||
}
|
||||
|
||||
@patch('lms.djangoapps.learner_home.views.get_encoded_course_sharing_utm_params')
|
||||
@patch("lms.djangoapps.learner_home.views.get_encoded_course_sharing_utm_params")
|
||||
def test_social_share_settings__empty(self, mock_get_utm_params):
|
||||
share_settings = {}
|
||||
mock_get_utm_params.return_value = {}
|
||||
@@ -461,23 +464,15 @@ class TestGetSocialShareSettings(TestCase):
|
||||
social_share_settings = get_social_share_settings()
|
||||
|
||||
assert social_share_settings == {
|
||||
"facebook": {
|
||||
"is_enabled": False,
|
||||
"brand": "edX.org",
|
||||
"utm_params": None
|
||||
},
|
||||
"twitter": {
|
||||
"is_enabled": False,
|
||||
"brand": "edX.org",
|
||||
"utm_params": None
|
||||
}
|
||||
"facebook": {"is_enabled": False, "brand": "edX.org", "utm_params": None},
|
||||
"twitter": {"is_enabled": False, "brand": "edX.org", "utm_params": None},
|
||||
}
|
||||
|
||||
|
||||
class TestGetCourseShareUrls(TestCase):
|
||||
""" Tests for get_course_share_urls """
|
||||
"""Tests for get_course_share_urls"""
|
||||
|
||||
@patch('lms.djangoapps.learner_home.views.get_link_for_about_page')
|
||||
@patch("lms.djangoapps.learner_home.views.get_link_for_about_page")
|
||||
def test_get_course_share_urls(self, mock_about_page_link):
|
||||
enrollments = CourseEnrollmentFactory.create_batch(3)
|
||||
course_share_urls = get_course_share_urls(enrollments)
|
||||
@@ -825,20 +820,30 @@ class TestDashboardMasquerade(BaseTestDashboardView):
|
||||
class TestCourseRecommendationApiView(SharedModuleStoreTestCase):
|
||||
"""Unit tests for the course recommendations on learner home page."""
|
||||
|
||||
password = 'test'
|
||||
url = reverse_lazy('learner_home:courses')
|
||||
password = "test"
|
||||
url = reverse_lazy("learner_home:courses")
|
||||
|
||||
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.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_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'
|
||||
"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)
|
||||
@@ -851,10 +856,13 @@ class TestCourseRecommendationApiView(SharedModuleStoreTestCase):
|
||||
self.assertEqual(response.data, None)
|
||||
|
||||
@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_no_recommendations_from_amplitude(self, mocked_get_course_data,
|
||||
mocked_get_personalized_course_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_recommendations_from_amplitude(
|
||||
self, mocked_get_course_data, mocked_get_personalized_course_recommendations
|
||||
):
|
||||
"""
|
||||
Verify API returns 400 if no course recommendations from amplitude.
|
||||
"""
|
||||
@@ -866,34 +874,54 @@ class TestCourseRecommendationApiView(SharedModuleStoreTestCase):
|
||||
self.assertEqual(response.data, None)
|
||||
|
||||
@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):
|
||||
@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_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)
|
||||
self.assertEqual(response.data.get('is_personalized_recommendation'), True)
|
||||
self.assertEqual(len(response.data.get('courses')), expected_recommendations_length)
|
||||
self.assertEqual(response.data.get("is_personalized_recommendation"), True)
|
||||
self.assertEqual(
|
||||
len(response.data.get("courses")), expected_recommendations_length
|
||||
)
|
||||
|
||||
@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):
|
||||
@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_personalized_course_recommendations.return_value = [
|
||||
False,
|
||||
self.recommended_courses,
|
||||
]
|
||||
mocked_get_course_data.return_value = self.course_data
|
||||
course_keys = ['course-v1:IBM+PY0101EN+Run_0', 'course-v1:UQx+IELTSx+Run_0', 'course-v1:MITx+6.00.1x+Run_0',
|
||||
'course-v1:HarvardX+CS50P+Run_0', 'course-v1:Harvard+CS50z+Run_0', 'course-v1:TUMx+QPLS2x+Run_0']
|
||||
course_keys = [
|
||||
"course-v1:IBM+PY0101EN+Run_0",
|
||||
"course-v1:UQx+IELTSx+Run_0",
|
||||
"course-v1:MITx+6.00.1x+Run_0",
|
||||
"course-v1:HarvardX+CS50P+Run_0",
|
||||
"course-v1:Harvard+CS50z+Run_0",
|
||||
"course-v1:TUMx+QPLS2x+Run_0",
|
||||
]
|
||||
expected_recommendations = 4
|
||||
# enrolling in 6 courses
|
||||
for course_key in course_keys:
|
||||
@@ -901,5 +929,5 @@ class TestCourseRecommendationApiView(SharedModuleStoreTestCase):
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data.get('is_personalized_recommendation'), True)
|
||||
self.assertEqual(len(response.data.get('courses')), expected_recommendations)
|
||||
self.assertEqual(response.data.get("is_personalized_recommendation"), True)
|
||||
self.assertEqual(len(response.data.get("courses")), expected_recommendations)
|
||||
|
||||
@@ -5,8 +5,16 @@ 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
|
||||
|
||||
from common.djangoapps.student.models import (
|
||||
get_user_by_username_or_email,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def exec_time_logged(func):
|
||||
@@ -34,27 +42,67 @@ def exec_time_logged(func):
|
||||
return wrap_func
|
||||
|
||||
|
||||
def get_masquerade_user(request):
|
||||
"""
|
||||
Determine if the user is masquerading
|
||||
|
||||
Returns:
|
||||
- masquerade_user if allowed and masquerade user found
|
||||
- None if not masquerading
|
||||
|
||||
Raises:
|
||||
- PermissionDenied if user is not staff
|
||||
- NotFound if masquerade user does not exist
|
||||
"""
|
||||
if request.GET.get("user"):
|
||||
if not request.user.is_staff:
|
||||
log.info(
|
||||
f"[Learner Home] {request.user.username} attempted to masquerade but is not staff"
|
||||
)
|
||||
raise PermissionDenied()
|
||||
|
||||
masquerade_identifier = request.GET.get("user")
|
||||
try:
|
||||
masquerade_user = get_user_by_username_or_email(masquerade_identifier)
|
||||
except User.DoesNotExist:
|
||||
raise NotFound() # pylint: disable=raise-missing-from
|
||||
except MultipleObjectsReturned:
|
||||
msg = (
|
||||
f"[Learner Home] {masquerade_identifier} could refer to multiple learners. "
|
||||
" Defaulting to username."
|
||||
)
|
||||
log.info(msg)
|
||||
masquerade_user = User.objects.get(username=masquerade_identifier)
|
||||
|
||||
success_msg = (
|
||||
f"[Learner Home] {request.user.username} masquerades as "
|
||||
f"{masquerade_user.username} - {masquerade_user.email}"
|
||||
)
|
||||
log.info(success_msg)
|
||||
return masquerade_user
|
||||
|
||||
|
||||
def get_personalized_course_recommendations(user_id):
|
||||
""" Get personalize recommendations from Amplitude. """
|
||||
"""Get personalize recommendations from Amplitude."""
|
||||
headers = {
|
||||
'Authorization': f'Api-Key {settings.AMPLITUDE_API_KEY}',
|
||||
'Content-Type': 'application/json'
|
||||
"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,
|
||||
"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()
|
||||
recommendations = response.get('userData', {}).get('recommendations', [])
|
||||
recommendations = response.get("userData", {}).get("recommendations", [])
|
||||
if recommendations:
|
||||
is_control = recommendations[0].get('is_control')
|
||||
recommended_course_keys = recommendations[0].get('items')
|
||||
is_control = recommendations[0].get("is_control")
|
||||
recommended_course_keys = recommendations[0].get("items")
|
||||
return is_control, recommended_course_keys
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
log.warning(f'Cannot get recommendations from Amplitude: {ex}')
|
||||
log.warning(f"Cannot get recommendations from Amplitude: {ex}")
|
||||
|
||||
return True, []
|
||||
|
||||
@@ -3,28 +3,18 @@ Views for the learner dashboard.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
from time import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import MultipleObjectsReturned
|
||||
from django.urls import reverse
|
||||
from completion.exceptions import UnavailableCompletionData
|
||||
from completion.utilities import get_key_to_last_completed_block
|
||||
from common.djangoapps.util.course import (
|
||||
get_encoded_course_sharing_utm_params,
|
||||
get_link_for_about_page,
|
||||
)
|
||||
from edx_django_utils import monitoring as monitoring_utils
|
||||
from edx_django_utils.monitoring import function_trace
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from edx_rest_framework_extensions.auth.session.authentication import (
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from openedx.features.course_duration_limits.access import (
|
||||
get_user_course_expiration_date,
|
||||
)
|
||||
from rest_framework.exceptions import PermissionDenied, NotFound
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.generics import RetrieveAPIView
|
||||
@@ -32,18 +22,22 @@ from rest_framework.views import APIView
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.edxmako.shortcuts import marketing_link
|
||||
from common.djangoapps.student.helpers import cert_info
|
||||
from common.djangoapps.student.models import (
|
||||
CourseEnrollment,
|
||||
get_user_by_username_or_email,
|
||||
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,
|
||||
get_course_enrollments,
|
||||
get_org_black_and_whitelist_for_site,
|
||||
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,
|
||||
)
|
||||
from common.djangoapps.util.milestones_helpers import (
|
||||
get_pre_requisite_courses_not_completed,
|
||||
)
|
||||
@@ -55,25 +49,29 @@ from lms.djangoapps.courseware.access_utils import (
|
||||
check_course_open_for_learner,
|
||||
)
|
||||
from lms.djangoapps.learner_home.serializers import LearnerDashboardSerializer
|
||||
from lms.djangoapps.learner_home.waffle import should_show_learner_home_amplitude_recommendations
|
||||
from lms.djangoapps.learner_home.waffle import (
|
||||
should_show_learner_home_amplitude_recommendations,
|
||||
)
|
||||
from lms.djangoapps.learner_home.utils import (
|
||||
get_masquerade_user,
|
||||
get_personalized_course_recommendations,
|
||||
exec_time_logged,
|
||||
)
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_data
|
||||
from openedx.features.course_duration_limits.access import (
|
||||
get_user_course_expiration_date,
|
||||
)
|
||||
from openedx.features.enterprise_support.api import (
|
||||
enterprise_customer_from_session_or_learner_data,
|
||||
get_enterprise_learner_data_from_db,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_platform_settings")
|
||||
def get_platform_settings():
|
||||
"""Get settings used for platform level connections: emails, url routes, etc."""
|
||||
|
||||
@@ -84,7 +82,7 @@ def get_platform_settings():
|
||||
}
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_user_account_confirmation_info")
|
||||
def get_user_account_confirmation_info(user):
|
||||
"""Determine if a user needs to verify their account and related URL info"""
|
||||
|
||||
@@ -103,7 +101,7 @@ def get_user_account_confirmation_info(user):
|
||||
return email_confirmation
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_enrollments")
|
||||
def get_enrollments(user, org_allow_list, org_block_list, course_limit=None):
|
||||
"""Get enrollments and enrollment course modes for user"""
|
||||
|
||||
@@ -143,7 +141,7 @@ def get_enrollments(user, org_allow_list, org_block_list, course_limit=None):
|
||||
return course_enrollments, course_mode_info
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_entitlements")
|
||||
def get_entitlements(user, org_allow_list, org_block_list):
|
||||
"""Get entitlements for the user"""
|
||||
(
|
||||
@@ -169,7 +167,7 @@ def get_entitlements(user, org_allow_list, org_block_list):
|
||||
)
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_course_overviews_for_pseudo_sessions")
|
||||
def get_course_overviews_for_pseudo_sessions(unfulfilled_entitlement_pseudo_sessions):
|
||||
"""
|
||||
Get course overviews for entitlement pseudo sessions. This is required for
|
||||
@@ -188,6 +186,7 @@ def get_course_overviews_for_pseudo_sessions(unfulfilled_entitlement_pseudo_sess
|
||||
return CourseOverview.get_from_ids(course_ids)
|
||||
|
||||
|
||||
@function_trace("get_email_settings_info")
|
||||
def get_email_settings_info(user, course_enrollments):
|
||||
"""
|
||||
Given a user and enrollments, determine which courses allow bulk email (show_email_settings_for)
|
||||
@@ -207,7 +206,7 @@ def get_email_settings_info(user, course_enrollments):
|
||||
return show_email_settings_for, course_optouts
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_enterprise_customer")
|
||||
def get_enterprise_customer(user, request, is_masquerading):
|
||||
"""
|
||||
If we are not masquerading, try to load the enterprise learner from session data, falling back to the db.
|
||||
@@ -220,7 +219,7 @@ def get_enterprise_customer(user, request, is_masquerading):
|
||||
return enterprise_customer_from_session_or_learner_data(request)
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_ecommerce_payment_page")
|
||||
def get_ecommerce_payment_page(user):
|
||||
"""Determine the ecommerce payment page URL if enabled for this user"""
|
||||
ecommerce_service = EcommerceService()
|
||||
@@ -231,7 +230,7 @@ def get_ecommerce_payment_page(user):
|
||||
)
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_cert_statuses")
|
||||
def get_cert_statuses(user, course_enrollments):
|
||||
"""Get cert status by course for user enrollments"""
|
||||
return {
|
||||
@@ -240,13 +239,13 @@ def get_cert_statuses(user, course_enrollments):
|
||||
}
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_org_block_and_allow_lists")
|
||||
def get_org_block_and_allow_lists():
|
||||
"""Proxy for get_org_black_and_whitelist_for_site to allow for modification / profiling"""
|
||||
return get_org_black_and_whitelist_for_site()
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_resume_urls_for_course_enrollments")
|
||||
def get_resume_urls_for_course_enrollments(user, course_enrollments):
|
||||
"""
|
||||
Modeled off of get_resume_urls_for_enrollments but removes check for actual presence of block
|
||||
@@ -289,7 +288,7 @@ def _get_courses_with_unmet_prerequisites(user, course_enrollments):
|
||||
return get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("check_course_access")
|
||||
def check_course_access(user, course_enrollments):
|
||||
"""
|
||||
Wrapper for checks surrounding user ability to view courseware
|
||||
@@ -326,7 +325,7 @@ def check_course_access(user, course_enrollments):
|
||||
return course_access_dict
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_course_programs")
|
||||
def get_course_programs(user, course_enrollments, site):
|
||||
"""
|
||||
Get programs related to the courses the user is enrolled in.
|
||||
@@ -343,7 +342,7 @@ def get_course_programs(user, course_enrollments, site):
|
||||
return meter.invert_programs()
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_suggested_courses")
|
||||
def get_suggested_courses():
|
||||
"""
|
||||
Currently just returns general recommendations from settings
|
||||
@@ -357,7 +356,7 @@ def get_suggested_courses():
|
||||
)
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_social_share_settings")
|
||||
def get_social_share_settings():
|
||||
"""Config around social media sharing campaigns"""
|
||||
|
||||
@@ -383,7 +382,7 @@ def get_social_share_settings():
|
||||
}
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_course_share_urls")
|
||||
def get_course_share_urls(course_enrollments):
|
||||
"""Get course URLs for sharing on social media"""
|
||||
return {
|
||||
@@ -392,7 +391,7 @@ def get_course_share_urls(course_enrollments):
|
||||
}
|
||||
|
||||
|
||||
@exec_time_logged
|
||||
@function_trace("get_audit_access_deadlines")
|
||||
def get_audit_access_deadlines(user, course_enrollments):
|
||||
"""
|
||||
Get audit access deadlines for each course enrollment
|
||||
@@ -408,40 +407,39 @@ def get_audit_access_deadlines(user, course_enrollments):
|
||||
}
|
||||
|
||||
|
||||
@function_trace("get_user_grade_passing_statuses")
|
||||
def get_user_grade_passing_statuses(course_enrollments):
|
||||
"""
|
||||
Get "passing" status for user in each course
|
||||
|
||||
Returns:
|
||||
- Dict {course_id: <boolean (True = Passing grade, False = Failing grade)>}
|
||||
"""
|
||||
return {
|
||||
course_enrollment.course_id: user_has_passing_grade_in_course(course_enrollment)
|
||||
for course_enrollment in course_enrollments
|
||||
}
|
||||
|
||||
|
||||
@function_trace("serialize_learner_home_data")
|
||||
def serialize_learner_home_data(data, context):
|
||||
"""Wrapper for serialization so we can profile"""
|
||||
return LearnerDashboardSerializer(data, context=context).data
|
||||
|
||||
|
||||
class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument
|
||||
"""List of courses a user is enrolled in or entitled to"""
|
||||
|
||||
def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
|
||||
if request.GET.get("user"):
|
||||
if not request.user.is_staff:
|
||||
logger.info(
|
||||
f"[Learner Home] {request.user.username} attempted to masquerade but is not staff"
|
||||
)
|
||||
raise PermissionDenied()
|
||||
"""Get masquerade user and proxy to init request"""
|
||||
masquerade_user = get_masquerade_user(request)
|
||||
|
||||
masquerade_identifier = request.GET.get("user")
|
||||
try:
|
||||
masquerade_user = get_user_by_username_or_email(masquerade_identifier)
|
||||
except User.DoesNotExist:
|
||||
raise NotFound() # pylint: disable=raise-missing-from
|
||||
except MultipleObjectsReturned:
|
||||
msg = (
|
||||
f"[Learner Home] {masquerade_identifier} could refer to multiple learners. "
|
||||
" Defaulting to username."
|
||||
)
|
||||
logger.info(msg)
|
||||
masquerade_user = User.objects.get(username=masquerade_identifier)
|
||||
|
||||
success_msg = (
|
||||
f"[Learner Home] {request.user.username} masquerades as "
|
||||
f"{masquerade_user.username} - {masquerade_user.email}"
|
||||
)
|
||||
logger.info(success_msg)
|
||||
return self._initialize(masquerade_user, True)
|
||||
if masquerade_user:
|
||||
return self._initialize(masquerade_user, is_masquerade=True)
|
||||
else:
|
||||
return self._initialize(request.user, False)
|
||||
return self._initialize(request.user)
|
||||
|
||||
def _initialize(self, user, is_masquerade):
|
||||
def _initialize(self, user, is_masquerade=False):
|
||||
"""
|
||||
Load information required for displaying the learner home
|
||||
"""
|
||||
@@ -484,6 +482,9 @@ class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument
|
||||
user, course_enrollments
|
||||
)
|
||||
|
||||
# Get grade passing status by course
|
||||
grade_statuses = get_user_grade_passing_statuses(course_enrollments)
|
||||
|
||||
# Get cert status by course
|
||||
cert_statuses = get_cert_statuses(user, course_enrollments)
|
||||
|
||||
@@ -524,6 +525,7 @@ class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument
|
||||
"course_mode_info": course_mode_info,
|
||||
"course_optouts": course_optouts,
|
||||
"course_access_checks": course_access_checks,
|
||||
"grade_statuses": grade_statuses,
|
||||
"resume_course_urls": resume_button_urls,
|
||||
"course_share_urls": course_share_urls,
|
||||
"show_email_settings_for": show_email_settings_for,
|
||||
@@ -534,16 +536,7 @@ class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument
|
||||
"programs": programs,
|
||||
}
|
||||
|
||||
t1 = time()
|
||||
|
||||
response_data = LearnerDashboardSerializer(
|
||||
learner_dash_data, context=context
|
||||
).data
|
||||
|
||||
t2 = time()
|
||||
logger.info(
|
||||
f"Finished serializing home info for {user.username} in {(t2-t1):.4f}s"
|
||||
)
|
||||
response_data = serialize_learner_home_data(learner_dash_data, context)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
@@ -555,11 +548,14 @@ class CourseRecommendationApiView(APIView):
|
||||
GET /api/learner_home/recommendation/courses/
|
||||
"""
|
||||
|
||||
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser,)
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request):
|
||||
""" Retrieves course recommendations details of a user in a specified course. """
|
||||
"""Retrieves course recommendations details of a user in a specified course."""
|
||||
if not should_show_learner_home_amplitude_recommendations():
|
||||
return Response(status=400)
|
||||
|
||||
@@ -569,10 +565,10 @@ class CourseRecommendationApiView(APIView):
|
||||
# Emits an event to track student dashboard page visits.
|
||||
segment.track(
|
||||
user_id,
|
||||
'edx.bi.user.recommendations.viewed',
|
||||
"edx.bi.user.recommendations.viewed",
|
||||
{
|
||||
'is_personalized_recommendation': not is_control,
|
||||
}
|
||||
"is_personalized_recommendation": not is_control,
|
||||
},
|
||||
)
|
||||
|
||||
if is_control or not course_keys:
|
||||
@@ -580,24 +576,38 @@ class CourseRecommendationApiView(APIView):
|
||||
|
||||
recommended_courses = []
|
||||
user_enrolled_course_keys = set()
|
||||
fields = ['title', 'owners', 'marketing_url']
|
||||
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}'
|
||||
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]
|
||||
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')
|
||||
})
|
||||
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"),
|
||||
}
|
||||
)
|
||||
|
||||
segment.track(user_id, 'edx.bi.user.recommendations.count', {'count': len(recommended_courses)})
|
||||
return Response({'courses': recommended_courses, 'is_personalized_recommendation': not is_control}, status=200)
|
||||
segment.track(
|
||||
user_id,
|
||||
"edx.bi.user.recommendations.count",
|
||||
{"count": len(recommended_courses)},
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"courses": recommended_courses,
|
||||
"is_personalized_recommendation": not is_control,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ waffle switches for the teams app.
|
||||
"""
|
||||
|
||||
from edx_toggles.toggles import WaffleFlag
|
||||
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
|
||||
# Namespace for Learner Home MFE waffle flags.
|
||||
|
||||
Reference in New Issue
Block a user