290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""
|
|
Additional utilities for Learner Recommendations.
|
|
"""
|
|
import logging
|
|
import requests
|
|
|
|
from algoliasearch.exceptions import RequestException, AlgoliaUnreachableHostException
|
|
from algoliasearch.search_client import SearchClient
|
|
from django.conf import settings
|
|
|
|
from common.djangoapps.student.models import CourseEnrollment
|
|
from openedx.core.djangoapps.catalog.utils import get_course_data, get_programs
|
|
from lms.djangoapps.program_enrollments.api import fetch_program_enrollments_by_student
|
|
|
|
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 _remove_user_enrolled_course_keys(user, course_keys):
|
|
"""
|
|
Remove the course keys a user is already enrolled in
|
|
and returns enrollable course keys.
|
|
"""
|
|
user_enrolled_course_keys = set()
|
|
course_enrollments = CourseEnrollment.enrollments_for_user(user)
|
|
|
|
for course_enrollment in course_enrollments:
|
|
course_key = f"{course_enrollment.course_id.org}+{course_enrollment.course_id.course}"
|
|
user_enrolled_course_keys.add(course_key)
|
|
|
|
enrollable_course_keys = [course_key for course_key in course_keys if course_key not in user_enrolled_course_keys]
|
|
return enrollable_course_keys
|
|
|
|
|
|
def _has_country_restrictions(product, user_country):
|
|
"""
|
|
Helper method that tell whether the product (course or program) has any country restrictions.
|
|
A product is restricted for the user if the country in which user is logged in from:
|
|
- is in the "block list" or
|
|
- is not in the "allow list" if the "allow list" is not empty. If it is empty, then all locations can access it.
|
|
Args:
|
|
product: course/program
|
|
user_country (string): country the user is logged in from
|
|
|
|
Returns:
|
|
True if the product is restricted in the country and False otherwise
|
|
"""
|
|
if not user_country:
|
|
return False
|
|
|
|
allow_list, block_list = [], []
|
|
location_restriction = product.get("location_restriction", None)
|
|
if location_restriction:
|
|
restriction_type = location_restriction.get("restriction_type")
|
|
countries = location_restriction.get("countries")
|
|
if restriction_type == "allowlist":
|
|
allow_list = countries
|
|
elif restriction_type == "blocklist":
|
|
block_list = countries
|
|
|
|
return user_country in block_list or (bool(allow_list) and user_country not in allow_list)
|
|
|
|
|
|
def _get_program_duration(weeks):
|
|
"""
|
|
Helper method that returns the program duration in textual form.
|
|
"""
|
|
total_months = round(weeks / 4)
|
|
|
|
if total_months < 1:
|
|
return f'{total_months} weeks'
|
|
|
|
if 1 <= total_months < 12:
|
|
return f'{total_months} months'
|
|
|
|
total_years = round(total_months / 12)
|
|
total_remainder_months = round(total_months % 12)
|
|
|
|
if total_remainder_months == 0:
|
|
return f'{total_years} years'
|
|
|
|
if total_years == 1 and total_remainder_months == 1:
|
|
return f'1 year {total_remainder_months} months'
|
|
|
|
if total_remainder_months == 1:
|
|
return f'{total_years} years 1 months'
|
|
|
|
else:
|
|
return f'{total_years} years {total_remainder_months} months'
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
def get_amplitude_course_recommendations(user_id, recommendation_id):
|
|
"""
|
|
Get personalized recommendations from Amplitude.
|
|
|
|
Args:
|
|
user_id: The user for which the recommendations need to be pulled
|
|
recommendation_id: Amplitude model id
|
|
|
|
Returns:
|
|
is_control (bool): Control group value for the user
|
|
has_is_control (bool): Boolean value indicating if the control group for
|
|
the user has been decided.
|
|
recommended_course_keys (list): Course keys returned by Amplitude.
|
|
"""
|
|
headers = {
|
|
"Authorization": f"Api-Key {settings.AMPLITUDE_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
params = {
|
|
"user_id": user_id,
|
|
"get_recs": True,
|
|
"rec_id": recommendation_id,
|
|
}
|
|
response = requests.get(settings.AMPLITUDE_URL, params=params, headers=headers)
|
|
if response.status_code == 200:
|
|
response = response.json()
|
|
recommendations = response.get("userData", {}).get("recommendations", [])
|
|
if recommendations:
|
|
is_control = recommendations[0].get("is_control")
|
|
has_is_control = recommendations[0].get("has_is_control")
|
|
recommended_course_keys = recommendations[0].get("items")
|
|
return is_control, has_is_control, recommended_course_keys
|
|
|
|
return True, False, []
|
|
|
|
|
|
def filter_recommended_courses(
|
|
user,
|
|
unfiltered_course_keys,
|
|
recommendation_count=10,
|
|
user_country_code=None,
|
|
request_course=None,
|
|
):
|
|
"""
|
|
Returns the filtered course recommendations. The unfiltered course keys
|
|
pass through the following filters:
|
|
1. Remove courses that a user is already enrolled in.
|
|
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.
|
|
|
|
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",
|
|
]
|
|
|
|
# Remove the course keys a user is already enrolled in
|
|
enrollable_course_keys = _remove_user_enrolled_course_keys(user, unfiltered_course_keys)
|
|
|
|
# If user is seeing the recommendations on a course about page, filter that course out of recommendations
|
|
recommended_course_keys = [
|
|
course_key
|
|
for course_key in enrollable_course_keys
|
|
if course_key != request_course
|
|
]
|
|
|
|
for course_id in recommended_course_keys:
|
|
if len(filtered_recommended_courses) >= recommendation_count:
|
|
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)):
|
|
filtered_recommended_courses.append(course_data)
|
|
|
|
return filtered_recommended_courses
|
|
|
|
|
|
def get_programs_based_on_course(request_course, country_code, user):
|
|
"""
|
|
Returns a program for the course. If a course is part of multiple programs,
|
|
this function returns the program with the highest price.
|
|
"""
|
|
max_price, max_price_program = 0, {}
|
|
programs = get_programs(course=request_course)
|
|
|
|
if not programs:
|
|
return None
|
|
|
|
for program in programs:
|
|
if program.get('status') != 'active' or _has_country_restrictions(program, country_code):
|
|
continue
|
|
|
|
price = program['price_ranges'][0]['total']
|
|
if price > max_price:
|
|
if fetch_program_enrollments_by_student(program_uuids=[program.get('uuid')], user=user).exists():
|
|
continue
|
|
|
|
course_keys = [
|
|
course['key']
|
|
for course in program.get('courses', [])
|
|
if course.get('key') and course.get('key') != request_course
|
|
]
|
|
if _remove_user_enrolled_course_keys(user, course_keys):
|
|
max_price_program = program
|
|
max_price = price
|
|
|
|
if not max_price_program:
|
|
return None
|
|
|
|
course_pacing_type, total_weeks_to_complete = '', 0
|
|
for course in max_price_program.get('courses'):
|
|
for course_run in course.get('course_runs'):
|
|
if course_run.get('status') == 'published':
|
|
if not course_pacing_type:
|
|
course_pacing_type = course_run.get("pacing_type")
|
|
total_weeks_to_complete += int(course_run.get("weeks_to_complete"))
|
|
|
|
program_upsell = {
|
|
"title": max_price_program.get('title'),
|
|
"marketing_url": max_price_program.get('marketing_url'),
|
|
"courses_count": len(max_price_program.get('courses')),
|
|
"pacing_type": course_pacing_type,
|
|
"weeks_to_complete": _get_program_duration(total_weeks_to_complete),
|
|
"min_hours": max_price_program.get('min_hours_effort_per_week'),
|
|
"max_hours": max_price_program.get('max_hours_effort_per_week'),
|
|
"type": max_price_program.get('type'),
|
|
}
|
|
|
|
return program_upsell
|