diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 808294b030..28e08fb16c 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -16,7 +16,7 @@ jobs: - module-name: lms-1 path: "--django-settings-module=lms.envs.test lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/ lms/djangoapps/save_for_later/" - module-name: lms-2 - path: "--django-settings-module=lms.envs.test lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" + path: "--django-settings-module=lms.envs.test lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_recommendations/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" - module-name: openedx-1 path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/" - module-name: openedx-2 diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 0549a5ca0d..c250b00545 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -54,6 +54,7 @@ "lms/djangoapps/instructor_task/", "lms/djangoapps/learner_dashboard/", "lms/djangoapps/learner_home/", + "lms/djangoapps/learner_recommendations/", "lms/djangoapps/lms_initialization/", "lms/djangoapps/lms_xblock/", "lms/djangoapps/lti_provider/", diff --git a/lms/djangoapps/learner_recommendations/__init__.py b/lms/djangoapps/learner_recommendations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_recommendations/test_views.py b/lms/djangoapps/learner_recommendations/test_views.py new file mode 100644 index 0000000000..2b50bfa8f0 --- /dev/null +++ b/lms/djangoapps/learner_recommendations/test_views.py @@ -0,0 +1,134 @@ +""" +Tests for Learner Recommendations views and related functions. +""" + +import json +from unittest import mock + +from django.urls import reverse_lazy +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import UserFactory + + +class TestAlgoliaCoursesSearchView(APITestCase): + """Unit tests for the Algolia courses recommendation.""" + + password = "test" + view_url = reverse_lazy( + "learner_recommendations:algolia_courses", + kwargs={'course_id': 'course-v1:test+TestX+Test_Course'} + ) + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.expected_courses_recommendation = { + "hits": [ + { + "availability": ["Available now"], + "level": ["Introductory"], + "marketing_url": "https://marketing-site.com/course/monsters-anatomy-101", + "card_image_url": "https://card-site.com/course/monsters-anatomy-101", + "active_run_key": "course-v1:test+TestX+Test_Course_1", + "skills": [{"skill": "skill_1"}, {"skill": "skill_2"}], + }, + { + "availability": ["Available now"], + "level": ["Intermediate"], + "marketing_url": "https://marketing-site.com/course/monsters-anatomy-101", + "card_image_url": "https://card-site.com/course/monsters-anatomy-101", + "active_run_key": "course-v1:test+TestX+Test_Course_2", + "skills": [{"skill": "skill_1"}, {"skill": "skill_2"}], + } + ], + "nbHits": 2 + } + + def test_unauthenticated_request(self): + """ + Test unauthenticated request to Algolia courses recommendation API view. + """ + + response = self.client.get(self.view_url) + self.assertEqual(response.status_code, 401) + + @mock.patch( + "lms.djangoapps.learner_recommendations.views.get_course_data" + ) + @mock.patch( + "lms.djangoapps.learner_recommendations.views.get_course_run_details" + ) + def test_no_course_data( + self, + mocked_get_course_run_details, + mocked_get_course_data + ): + """ + Verify API returns empty response if no course data found. + """ + mocked_get_course_run_details.return_value = {"course": "edX+DemoX"} + mocked_get_course_data.return_value = None + + self.client.login(username=self.user.username, password=self.password) + response = self.client.get(self.view_url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("courses"), []) + self.assertEqual(response_content.get("count"), 0) + + @mock.patch( + "lms.djangoapps.learner_recommendations.views.get_course_data" + ) + @mock.patch( + "lms.djangoapps.learner_recommendations.views.get_course_run_details" + ) + def test_no_course_skill_names( + self, + mocked_get_course_run_details, + mocked_get_course_data + ): + """ + Verify API returns empty response if no course skill_names found. + """ + mocked_get_course_run_details.return_value = {"course": "edX+DemoX"} + mocked_get_course_data.return_value = {"level_type": "Advanced", "skill_names": []} + + self.client.login(username=self.user.username, password=self.password) + response = self.client.get(self.view_url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("courses"), []) + self.assertEqual(response_content.get("count"), 0) + + @mock.patch( + "lms.djangoapps.learner_recommendations.views.get_algolia_courses_recommendation" + ) + @mock.patch( + "lms.djangoapps.learner_recommendations.views.get_course_run_details" + ) + @mock.patch( + "lms.djangoapps.learner_recommendations.views.get_course_data" + ) + def test_recommendations( + self, + mocked_get_course_data, + mocked_get_course_run_details, + mocked_get_algolia_courses_recommendation + ): + """ + Verify API response structure. + """ + mocked_get_course_run_details.return_value = {"course": "edX+DemoX"} + mocked_get_course_data.return_value = {"level_type": "Advanced", "skill_names": ["skill_1", "skill_2"]} + mocked_get_algolia_courses_recommendation.return_value = self.expected_courses_recommendation + + self.client.login(username=self.user.username, password=self.password) + response = self.client.get(self.view_url) + self.assertEqual(response.status_code, 200) + + response_content = json.loads(response.content) + self.assertEqual(response_content.get("courses"), self.expected_courses_recommendation["hits"]) + self.assertEqual(response_content.get("count"), self.expected_courses_recommendation["nbHits"]) diff --git a/lms/djangoapps/learner_recommendations/urls.py b/lms/djangoapps/learner_recommendations/urls.py new file mode 100644 index 0000000000..d4d3b308e7 --- /dev/null +++ b/lms/djangoapps/learner_recommendations/urls.py @@ -0,0 +1,16 @@ +""" +Learner Recommendations URL routing configuration. +""" + +from django.conf import settings +from django.urls import re_path + +from lms.djangoapps.learner_recommendations import views + +app_name = "learner_recommendations" + +urlpatterns = [ + re_path(fr'^algolia/courses/{settings.COURSE_ID_PATTERN}/$', + views.AlgoliaCoursesSearchView.as_view(), + name='algolia_courses'), +] diff --git a/lms/djangoapps/learner_recommendations/utils.py b/lms/djangoapps/learner_recommendations/utils.py new file mode 100644 index 0000000000..0574d31c02 --- /dev/null +++ b/lms/djangoapps/learner_recommendations/utils.py @@ -0,0 +1,78 @@ +""" +Additional utilities for Learner Recommendations. +""" + +import logging + +from algoliasearch.exceptions import RequestException, AlgoliaUnreachableHostException +from algoliasearch.search_client import SearchClient +from django.conf import settings + + +log = logging.getLogger(__name__) + +COURSE_LEVELS = [ + 'Introductory', + 'Intermediate', + 'Advanced' +] + + +class AlgoliaClient: + """ Class for instantiating an Algolia search client instance. """ + algolia_client = None + algolia_app_id = settings.ALGOLIA_APP_ID + algolia_search_api_key = settings.ALGOLIA_SEARCH_API_KEY + + @classmethod + def get_algolia_client(cls): + """ Get Algolia client instance. """ + if not cls.algolia_client: + if not (cls.algolia_app_id and cls.algolia_search_api_key): + return None + + cls.algolia_client = SearchClient.create(cls.algolia_app_id, cls.algolia_search_api_key) + + return cls.algolia_client + + +def get_algolia_courses_recommendation(course_data): + """ + Get courses recommendation from Algolia search. + + Args: + course_data (dict): Course data to create the search query. + + Returns: + Response object with courses recommendation from Algolia search. + """ + algolia_client = AlgoliaClient.get_algolia_client() + + search_query = " ".join(course_data["skill_names"]) + searchable_course_levels = [ + f"level:{course_level}" + for course_level in COURSE_LEVELS + if course_level != course_data["level_type"] + ] + if algolia_client and search_query: + algolia_index = algolia_client.init_index(settings.ALGOLIA_COURSES_RECOMMENDATION_INDEX_NAME) + try: + # Algolia search filter criteria: + # - Product type: Course + # - Courses are available (enrollable) + # - Courses should not have the same course level as the current course + # - Exclude current course from the results + results = algolia_index.search( + search_query, + { + "filters": f"NOT active_run_key:'{course_data['key']}'", + "facetFilters": ["availability:Available now", "product:Course", searchable_course_levels], + "optionalWords": f"{search_query}", + } + ) + + return results + except (AlgoliaUnreachableHostException, RequestException) as ex: + log.warning(f"Unexpected exception while attempting to fetch courses data from Algolia: {str(ex)}") + + return {} diff --git a/lms/djangoapps/learner_recommendations/views.py b/lms/djangoapps/learner_recommendations/views.py new file mode 100644 index 0000000000..743c12fe61 --- /dev/null +++ b/lms/djangoapps/learner_recommendations/views.py @@ -0,0 +1,48 @@ +""" +Views for Learner Recommendations. +""" + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import ( + SessionAuthenticationAllowInactiveUser, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx.core.djangoapps.catalog.utils import ( + get_course_data, + get_course_run_details +) +from lms.djangoapps.learner_recommendations.utils import ( + get_algolia_courses_recommendation +) + + +class AlgoliaCoursesSearchView(APIView): + """ + **Example Request** + + GET api/learner_recommendations/algolia/courses/{course_id}/ + """ + + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser,) + permission_classes = (IsAuthenticated,) + + def get(self, request, course_id): + """ Retrieves course recommendations from Algolia based on course skills. """ + + course_run_data = get_course_run_details(course_id, ["course"]) + course_key_str = course_run_data.get("course", None) + + # Fetching course level type and skills from discovery service. + course_data = get_course_data(course_key_str, ["level_type", "skill_names"]) + + # If discovery service fails to fetch data, we will not run recommendations engine. + if not course_data: + return Response({"courses": [], "count": 0}, status=200) + + course_data["key"] = course_id + response = get_algolia_courses_recommendation(course_data) + + return Response({"courses": response.get("hits", []), "count": response.get("nbHits", 0)}, status=200) diff --git a/lms/envs/common.py b/lms/envs/common.py index e62bbbfe65..1821567cc9 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1521,6 +1521,11 @@ BRANCH_IO_KEY = '' OPTIMIZELY_PROJECT_ID = None OPTIMIZELY_FULLSTACK_SDK_KEY = None +######################## ALGOLIA SEARCH ########################### +ALGOLIA_APP_ID = None +ALGOLIA_SEARCH_API_KEY = None +ALGOLIA_COURSES_RECOMMENDATION_INDEX_NAME = '' + ######################## subdomain specific settings ########################### COURSE_LISTINGS = {} diff --git a/lms/urls.py b/lms/urls.py index f02b1ecadb..461b3ca062 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -198,6 +198,12 @@ urlpatterns = [ # Learner Home path('api/learner_home/', include('lms.djangoapps.learner_home.urls', namespace='learner_home')), + # Learner Recommendations + path( + 'api/learner_recommendations/', + include('lms.djangoapps.learner_recommendations.urls', namespace='learner_recommendations') + ), + path( 'api/experiments/', include( diff --git a/requirements/edx/base.in b/requirements/edx/base.in index b36dcb9eb5..bad7d635d6 100644 --- a/requirements/edx/base.in +++ b/requirements/edx/base.in @@ -25,6 +25,7 @@ # as development.in or testing.in instead. acid-xblock +algoliasearch # Algolia’s API client for indexed searching analytics-python # Used for Segment analytics attrs # Reduces boilerplate code involving class attributes Babel # Internationalization utilities, used for date formatting in a few places diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 79d013499a..bfc7f8271e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -16,6 +16,8 @@ aiohttp==3.8.3 # via geoip2 aiosignal==1.3.1 # via aiohttp +algoliasearch==2.6.2 + # via -r requirements/edx/base.in amqp==5.1.1 # via kombu analytics-python==1.4.0 @@ -958,6 +960,7 @@ regex==2022.10.31 requests==2.28.2 # via # -r requirements/edx/paver.txt + # algoliasearch # analytics-python # coreapi # django-oauth-toolkit diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c73ac0b2f9..ff00a7490c 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -22,6 +22,8 @@ aiosignal==1.3.1 # aiohttp alabaster==0.7.13 # via sphinx +algoliasearch==2.6.2 + # via -r requirements/edx/testing.txt amqp==5.1.1 # via # -r requirements/edx/testing.txt @@ -1337,6 +1339,7 @@ regex==2022.10.31 requests==2.28.2 # via # -r requirements/edx/testing.txt + # algoliasearch # analytics-python # coreapi # django-oauth-toolkit diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index b1d8329066..24ace7a890 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -20,6 +20,8 @@ aiosignal==1.3.1 # via # -r requirements/edx/base.txt # aiohttp +algoliasearch==2.6.2 + # via -r requirements/edx/base.txt amqp==5.1.1 # via # -r requirements/edx/base.txt @@ -1263,6 +1265,7 @@ regex==2022.10.31 requests==2.28.2 # via # -r requirements/edx/base.txt + # algoliasearch # analytics-python # coreapi # django-oauth-toolkit