feat: VAN-1221 - POC: Course skills based recommendations from Algolia
This commit is contained in:
2
.github/workflows/pylint-checks.yml
vendored
2
.github/workflows/pylint-checks.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -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/",
|
||||
|
||||
0
lms/djangoapps/learner_recommendations/__init__.py
Normal file
0
lms/djangoapps/learner_recommendations/__init__.py
Normal file
134
lms/djangoapps/learner_recommendations/test_views.py
Normal file
134
lms/djangoapps/learner_recommendations/test_views.py
Normal 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"])
|
||||
16
lms/djangoapps/learner_recommendations/urls.py
Normal file
16
lms/djangoapps/learner_recommendations/urls.py
Normal 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'),
|
||||
]
|
||||
78
lms/djangoapps/learner_recommendations/utils.py
Normal file
78
lms/djangoapps/learner_recommendations/utils.py
Normal 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 {}
|
||||
48
lms/djangoapps/learner_recommendations/views.py
Normal file
48
lms/djangoapps/learner_recommendations/views.py
Normal 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)
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user