From 178f1541d71e05e420efa8609d6508d1b71c4bd3 Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Wed, 2 Nov 2022 14:55:03 -0400 Subject: [PATCH] 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 --- common/djangoapps/student/views/dashboard.py | 3 +- lms/djangoapps/learner_home/serializers.py | 13 +- .../learner_home/test_serializers.py | 41 ++-- lms/djangoapps/learner_home/test_views.py | 130 +++++++----- lms/djangoapps/learner_home/utils.py | 68 ++++++- lms/djangoapps/learner_home/views.py | 186 +++++++++--------- lms/djangoapps/learner_home/waffle.py | 1 + 7 files changed, 266 insertions(+), 176 deletions(-) diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 39fc850163..cde9b5043c 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -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) diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index ddb761478b..620bc7455a 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -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() diff --git a/lms/djangoapps/learner_home/test_serializers.py b/lms/djangoapps/learner_home/test_serializers.py index 0afb45f68c..4e3708a79d 100644 --- a/lms/djangoapps/learner_home/test_serializers.py +++ b/lms/djangoapps/learner_home/test_serializers.py @@ -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)) diff --git a/lms/djangoapps/learner_home/test_views.py b/lms/djangoapps/learner_home/test_views.py index adfd7f8735..0ae90eb651 100644 --- a/lms/djangoapps/learner_home/test_views.py +++ b/lms/djangoapps/learner_home/test_views.py @@ -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) diff --git a/lms/djangoapps/learner_home/utils.py b/lms/djangoapps/learner_home/utils.py index a0c84aa153..25aaf8c8de 100644 --- a/lms/djangoapps/learner_home/utils.py +++ b/lms/djangoapps/learner_home/utils.py @@ -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, [] diff --git a/lms/djangoapps/learner_home/views.py b/lms/djangoapps/learner_home/views.py index 5322c62250..9f20ba0b97 100644 --- a/lms/djangoapps/learner_home/views.py +++ b/lms/djangoapps/learner_home/views.py @@ -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: } + """ + 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, + ) diff --git a/lms/djangoapps/learner_home/waffle.py b/lms/djangoapps/learner_home/waffle.py index 1dbb5e43e2..46459bcda8 100644 --- a/lms/djangoapps/learner_home/waffle.py +++ b/lms/djangoapps/learner_home/waffle.py @@ -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.