feat: VAN-1221 - POC: Course skills based recommendations from Algolia

This commit is contained in:
Shafqat Farhan
2023-01-03 02:46:23 +05:00
parent 3d2d02bffe
commit 68acdca9d7
13 changed files with 299 additions and 1 deletions

View File

@@ -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

View File

@@ -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/",

View File

@@ -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"])

View File

@@ -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'),
]

View File

@@ -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 {}

View File

@@ -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)

View File

@@ -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 = {}

View File

@@ -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(

View File

@@ -25,6 +25,7 @@
# as development.in or testing.in instead.
acid-xblock
algoliasearch # Algolias 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

View File

@@ -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

View File

@@ -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

View File

@@ -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