Files
edx-platform/lms/djangoapps/learner_recommendations/views.py
Jody Bailey ba1c018a2e feat: Cross Product Recommendations Logic (#32003)
* feat: added get_cross_product_recommendations method

* feat: Cross Product Recommendations Logic

* fix: lint errors

* fix: removed unused utility method get_programs_based_on_course

* fix: lint errors

* fix: views test case removed decorator

* fix: edited serializer to no longer extend from existing

* fix: included mocked data for tests and handled empty discovery responses

* fix: removed settings import

* test: add module docstring

* test: removed log resultitng in failure

* fix: moved recommendations dictionary to settings variable

* fix: cleaned up logic for Cross Product Recommendations view

* refactor: requested changes to view and serializer tests

* fix: fixed list passed into serializer in cross product view

* fix: added query string to course query and removed production course keys

* fix: moved query string argument value to separate variable
2023-04-07 13:17:17 +02:00

288 lines
10 KiB
Python

"""
Views for Learner Recommendations.
"""
import logging
from django.conf import settings
from ipware.ip import get_client_ip
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 opaque_keys.edx.keys import CourseKey
from django.core.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from common.djangoapps.track import segment
from common.djangoapps.student.toggles import show_fallback_recommendations
from openedx.core.djangoapps.geoinfo.api import country_code_from_ip
from openedx.features.enterprise_support.utils import is_enterprise_learner
from lms.djangoapps.learner_recommendations.toggles import (
enable_course_about_page_recommendations,
enable_dashboard_recommendations,
)
from lms.djangoapps.learner_recommendations.utils import (
get_amplitude_course_recommendations,
filter_recommended_courses,
is_user_enrolled_in_ut_austin_masters_program,
_has_country_restrictions,
)
from lms.djangoapps.learner_recommendations.serializers import CrossProductRecommendationsSerializer
from lms.djangoapps.learner_recommendations.utils import get_cross_product_recommendations
from openedx.core.djangoapps.catalog.utils import get_course_data
from lms.djangoapps.learner_recommendations.serializers import (
AboutPageRecommendationsSerializer,
DashboardRecommendationsSerializer,
)
log = logging.getLogger(__name__)
class AboutPageRecommendationsView(APIView):
"""
**Example Request**
GET api/learner_recommendations/amplitude/{course_id}/
"""
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser,)
permission_classes = (IsAuthenticated,)
recommendations_count = 4
def _emit_recommendations_viewed_event(
self,
user_id,
is_control,
recommended_courses,
amplitude_recommendations=True,
):
"""Emits an event to track recommendation experiment views."""
segment.track(
user_id,
"edx.bi.user.recommendations.viewed",
{
"is_control": is_control,
"amplitude_recommendations": amplitude_recommendations,
"course_key_array": [
course["key"] for course in recommended_courses
],
"page": "course_about_page",
},
)
def get(self, request, course_id):
"""
Returns
- Amplitude course recommendations for course about page
"""
if not enable_course_about_page_recommendations():
return Response(status=404)
if is_enterprise_learner(request.user):
raise PermissionDenied()
user = request.user
try:
is_control, has_is_control, course_keys = get_amplitude_course_recommendations(
user.id, settings.COURSE_ABOUT_PAGE_AMPLITUDE_RECOMMENDATION_ID
)
except Exception as err: # pylint: disable=broad-except
log.warning(f"Amplitude API failed for {user.id} due to: {err}")
return Response(status=404)
is_control = is_control if has_is_control else None
recommended_courses = []
if not (is_control or is_control is None):
ip_address = get_client_ip(request)[0]
user_country_code = country_code_from_ip(ip_address).upper()
recommended_courses = filter_recommended_courses(
user,
course_keys,
user_country_code=user_country_code,
request_course_key=course_id,
recommendation_count=self.recommendations_count
)
for course in recommended_courses:
course.update({
"active_course_run": course.get("course_runs")[0]
})
self._emit_recommendations_viewed_event(
user.id, is_control, recommended_courses
)
return Response(
AboutPageRecommendationsSerializer(
{
"courses": recommended_courses,
"is_control": is_control,
}
).data,
status=200,
)
class CrossProductRecommendationsView(APIView):
"""
**Example Request**
GET api/learner_recommendations/cross_product/{course_id}/
"""
def _empty_response(self):
return Response({"courses": []}, status=200)
def get(self, request, course_id):
"""
Returns cross product recommendation courses
"""
course_locator = CourseKey.from_string(course_id)
course_key = f'{course_locator.org}+{course_locator.course}'
associated_course_keys = get_cross_product_recommendations(course_key)
if not associated_course_keys:
return self._empty_response()
fields = [
"key",
"uuid",
"title",
"owners",
"image",
"url_slug",
"course_type",
"course_runs",
"location_restriction",
]
query_string = {'marketable_course_runs_only': 1}
course_data = [get_course_data(key, fields, query_string) for key in associated_course_keys]
filtered_courses = [course for course in course_data if course and course.get("course_runs")]
ip_address = get_client_ip(request)[0]
user_country_code = country_code_from_ip(ip_address).upper()
unrestricted_courses = []
for course in filtered_courses:
if not _has_country_restrictions(course, user_country_code):
unrestricted_courses.append(course)
if not unrestricted_courses:
return self._empty_response()
for course in unrestricted_courses:
course.update({
"active_course_run": course.get("course_runs")[0]
})
return Response(
CrossProductRecommendationsSerializer(
{
"courses": unrestricted_courses
}).data,
status=200
)
class DashboardRecommendationsApiView(APIView):
"""
API to get personalized recommendations from Amplitude.
**Example Request**
GET /api/learner_recommendations/courses/
"""
authentication_classes = (
JwtAuthentication,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated, NotJwtRestrictedApplication)
def get(self, request):
"""
Retrieves course recommendations details.
"""
if not enable_dashboard_recommendations():
return Response(status=404)
user_id = request.user.id
if is_user_enrolled_in_ut_austin_masters_program(request.user):
return self._recommendations_response(user_id, None, [], False)
fallback_recommendations = settings.GENERAL_RECOMMENDATIONS if show_fallback_recommendations() else []
try:
is_control, has_is_control, course_keys = get_amplitude_course_recommendations(
user_id, settings.DASHBOARD_AMPLITUDE_RECOMMENDATION_ID
)
except Exception as ex: # pylint: disable=broad-except
log.warning(f"Cannot get recommendations from Amplitude: {ex}")
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._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()
filtered_courses = filter_recommended_courses(
request.user, course_keys, user_country_code=user_country_code, recommendation_count=5
)
# If no courses are left after filtering already enrolled courses from
# the list of amplitude recommendations, show general recommendations
# to the user.
if not filtered_courses:
return self._recommendations_response(user_id, is_control, fallback_recommendations, False)
recommended_courses = list(map(self._course_data, filtered_courses))
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
):
"""Emits an event to track Learner Home page visits."""
segment.track(
user_id,
"edx.bi.user.recommendations.viewed",
{
"is_control": is_control,
"amplitude_recommendations": amplitude_recommendations,
"course_key_array": [course["course_key"] for course in recommended_courses],
"page": "dashboard",
},
)
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
)
return Response(
DashboardRecommendationsSerializer(
{
"courses": recommended_courses,
"is_control": is_control,
}
).data,
status=200,
)
def _course_data(self, course):
"""Helper method for personalized recommendation response"""
return {
"course_key": course.get("key"),
"title": course.get("title"),
"logo_image_url": course.get("owners")[0]["logo_image_url"] if course.get(
"owners") else "",
"marketing_url": course.get("marketing_url"),
}