From 1ef66409c9c21bde610ad74afa4c0fc06aa47069 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:31:52 +0500 Subject: [PATCH] feat: add masters program restrictions on recommendations (#31857) Co-authored-by: Syed Sajjad Hussain Shah --- .../api/v0/tests/test_views.py | 17 +++++++ .../learner_dashboard/api/v0/views.py | 36 +++++++------- .../recommendations/test_views.py | 19 ++++++++ .../learner_home/recommendations/views.py | 26 +++++----- .../learner_recommendations/utils.py | 48 +++++++++++++++++-- 5 files changed, 108 insertions(+), 38 deletions(-) diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py index f15593e0f2..1ec7d4959f 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -439,3 +439,20 @@ class TestCourseRecommendationApiView(TestCase): self.assertEqual( segment_mock.call_args[0][2]["is_control"], expected_is_control ) + + @mock.patch( + "lms.djangoapps.learner_dashboard.api.v0.views.is_user_enrolled_in_masters_program" + ) + def test_no_recommendations_for_masters_program_learners( + self, is_user_enrolled_in_masters_program_mock + ): + """ + Verify API returns no recommendations if a user is enrolled in masters program. + """ + is_user_enrolled_in_masters_program_mock.return_value = True + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.get("is_control"), None) + self.assertEqual(len(response.data.get("courses")), 0) diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py index 6fc17ab6c9..25db33805c 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -27,6 +27,7 @@ from openedx.core.djangoapps.programs.utils import ( from lms.djangoapps.learner_recommendations.utils import ( filter_recommended_courses, get_amplitude_course_recommendations, + is_user_enrolled_in_masters_program, ) @@ -391,10 +392,10 @@ class CourseRecommendationApiView(APIView): }, ) - def _general_recommendations_response(self, user_id, is_control, recommendations): + def _recommendations_response(self, user_id, is_control, recommendations, amplitude_recommendations): """Helper method for general recommendations response""" self._emit_recommendations_viewed_event( - user_id, is_control, recommendations, amplitude_recommendations=False + user_id, is_control, recommendations, amplitude_recommendations ) return Response( { @@ -417,9 +418,11 @@ class CourseRecommendationApiView(APIView): def get(self, request): """Retrieves course recommendations details of a user in a specified course.""" user_id = request.user.id - fallback_recommendations = ( - settings.GENERAL_RECOMMENDATIONS if show_fallback_recommendations() else [] - ) + + if is_user_enrolled_in_masters_program(request.user): + return self._recommendations_response(user_id, None, [], False) + + fallback_recommendations = settings.GENERAL_RECOMMENDATIONS if show_fallback_recommendations() else [] try: ( @@ -429,15 +432,15 @@ class CourseRecommendationApiView(APIView): ) = get_amplitude_course_recommendations(user_id, settings.DASHBOARD_AMPLITUDE_RECOMMENDATION_ID) except Exception as ex: # pylint: disable=broad-except logger.warning(f"Cannot get recommendations from Amplitude: {ex}") - return self._general_recommendations_response( - user_id, None, fallback_recommendations + return self._recommendations_response( + user_id, None, fallback_recommendations, False ) is_control = is_control if has_is_control else None if is_control or is_control is None or not course_keys: - return self._general_recommendations_response( - user_id, is_control, fallback_recommendations + return self._recommendations_response( + user_id, is_control, fallback_recommendations, False ) ip_address = get_client_ip(request)[0] @@ -446,18 +449,11 @@ class CourseRecommendationApiView(APIView): request.user, course_keys, user_country_code=user_country_code, recommendation_count=5 ) if not filtered_courses: - return self._general_recommendations_response( - user_id, is_control, fallback_recommendations + return self._recommendations_response( + user_id, is_control, fallback_recommendations, False ) recommended_courses = list(map(self._course_data, filtered_courses)) - self._emit_recommendations_viewed_event( - user_id, is_control, recommended_courses - ) - return Response( - { - "courses": recommended_courses, - "is_control": is_control, - }, - status=200, + return self._recommendations_response( + user_id, is_control, recommended_courses, True ) diff --git a/lms/djangoapps/learner_home/recommendations/test_views.py b/lms/djangoapps/learner_home/recommendations/test_views.py index c635cc38cf..9202f3018b 100644 --- a/lms/djangoapps/learner_home/recommendations/test_views.py +++ b/lms/djangoapps/learner_home/recommendations/test_views.py @@ -305,3 +305,22 @@ class TestCourseRecommendationApiView(TestCase): assert segment_track_mock.call_count == 1 assert segment_track_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed" self.assertEqual(segment_track_mock.call_args[0][2]["is_control"], expected_is_control) + + @override_waffle_flag(ENABLE_LEARNER_HOME_AMPLITUDE_RECOMMENDATIONS, active=True) + @mock.patch( + "lms.djangoapps.learner_home.recommendations.views.is_user_enrolled_in_masters_program" + ) + def test_no_recommendations_for_masters_program_learners( + self, is_user_enrolled_in_masters_program_mock + ): + """ + Verify API returns no recommendations if a user is enrolled in masters program. + """ + is_user_enrolled_in_masters_program_mock.return_value = True + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("isControl"), None) + self.assertEqual(response_content.get("courses"), []) diff --git a/lms/djangoapps/learner_home/recommendations/views.py b/lms/djangoapps/learner_home/recommendations/views.py index 4cfe885080..930c9cdb85 100644 --- a/lms/djangoapps/learner_home/recommendations/views.py +++ b/lms/djangoapps/learner_home/recommendations/views.py @@ -26,6 +26,7 @@ from lms.djangoapps.learner_home.recommendations.waffle import ( from lms.djangoapps.learner_recommendations.utils import ( filter_recommended_courses, get_amplitude_course_recommendations, + is_user_enrolled_in_masters_program, ) @@ -55,6 +56,10 @@ class CourseRecommendationApiView(APIView): return Response(status=404) user_id = request.user.id + + if is_user_enrolled_in_masters_program(request.user): + return self._recommendations_response(user_id, None, [], False) + fallback_recommendations = settings.GENERAL_RECOMMENDATIONS if show_fallback_recommendations() else [] try: @@ -63,11 +68,11 @@ class CourseRecommendationApiView(APIView): ) except Exception as ex: # pylint: disable=broad-except logger.warning(f"Cannot get recommendations from Amplitude: {ex}") - return self._general_recommendations_response(user_id, None, fallback_recommendations) + return self._recommendations_response(user_id, None, fallback_recommendations, False) is_control = is_control if has_is_control else None if is_control or is_control is None or not course_keys: - return self._general_recommendations_response(user_id, is_control, fallback_recommendations) + return self._recommendations_response(user_id, is_control, fallback_recommendations, False) ip_address = get_client_ip(request)[0] user_country_code = country_code_from_ip(ip_address).upper() @@ -78,19 +83,10 @@ class CourseRecommendationApiView(APIView): # the list of amplitude recommendations, show general recommendations # to the user. if not filtered_courses: - return self._general_recommendations_response(user_id, is_control, fallback_recommendations) + return self._recommendations_response(user_id, is_control, fallback_recommendations, False) recommended_courses = list(map(self._course_data, filtered_courses)) - self._emit_recommendations_viewed_event(user_id, is_control, recommended_courses) - return Response( - CourseRecommendationSerializer( - { - "courses": recommended_courses, - "is_control": is_control, - } - ).data, - status=200, - ) + return self._recommendations_response(user_id, is_control, recommended_courses, True) def _emit_recommendations_viewed_event( self, user_id, is_control, recommended_courses, amplitude_recommendations=True @@ -107,10 +103,10 @@ class CourseRecommendationApiView(APIView): }, ) - def _general_recommendations_response(self, user_id, is_control, recommended_courses): + def _recommendations_response(self, user_id, is_control, recommended_courses, amplitude_recommendations): """ Helper method for general recommendations response. """ self._emit_recommendations_viewed_event( - user_id, is_control, recommended_courses, amplitude_recommendations=False + user_id, is_control, recommended_courses, amplitude_recommendations ) return Response( CourseRecommendationSerializer( diff --git a/lms/djangoapps/learner_recommendations/utils.py b/lms/djangoapps/learner_recommendations/utils.py index f775cd9184..725ff5f8de 100644 --- a/lms/djangoapps/learner_recommendations/utils.py +++ b/lms/djangoapps/learner_recommendations/utils.py @@ -9,6 +9,7 @@ from algoliasearch.search_client import SearchClient from django.conf import settings from common.djangoapps.student.models import CourseEnrollment +from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses from openedx.core.djangoapps.catalog.utils import get_course_data, get_programs from lms.djangoapps.program_enrollments.api import fetch_program_enrollments_by_student @@ -191,6 +192,28 @@ def get_amplitude_course_recommendations(user_id, recommendation_id): return True, False, [] +def is_user_enrolled_in_masters_program(user): + """ + Checks if a user is enrolled in any masters program + + Args: + user: The user object + + Returns: + True if the user is enrolled in any masters program otherwise False + """ + program_enrollments = fetch_program_enrollments_by_student( + user=user, + program_enrollment_statuses=ProgramEnrollmentStatuses.__ACTIVE__, + ) + uuids = [enrollment.program_uuid for enrollment in program_enrollments] + enrolled_programs = get_programs(uuids=uuids) or [] + for enrolled_program in enrolled_programs: + if enrolled_program.get("type", None) == "Masters": + return True + return False + + def filter_recommended_courses( user, unfiltered_course_keys, @@ -205,12 +228,28 @@ def filter_recommended_courses( 2. If user is seeing the recommendations on a course about pages, filter that course out of recommendations. 3. Remove the courses which is restricted in user region. + Args: + user: The user for which the recommendations need to be pulled + unfiltered_course_keys: recommended course keys that needs to be filtered + recommendation_count: the maximum count of recommendations to be returned + user_country_code: if provided, will apply location restrictions to recommendations + request_course: if provided, will filter out that course from recommendations (used for course about page) + Returns: filtered_recommended_courses (list): A list of filtered course objects. """ filtered_recommended_courses = [] fields = [ - "key", "uuid", "title", "owners", "image", "url_slug", "course_runs", "location_restriction", "marketing_url", + "key", + "uuid", + "title", + "owners", + "image", + "url_slug", + "course_runs", + "location_restriction", + "marketing_url", + "programs", ] # Remove the course keys a user is already enrolled in @@ -228,8 +267,11 @@ def filter_recommended_courses( break course_data = get_course_data(course_id, fields, querystring={'marketable_course_runs_only': 1}) - if (course_data and course_data.get("course_runs", []) - and not _has_country_restrictions(course_data, user_country_code)): + if ( + course_data + and course_data.get("course_runs", []) + and not _has_country_restrictions(course_data, user_country_code) + ): filtered_recommended_courses.append(course_data) return filtered_recommended_courses