diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index 6f4d51ad356b..be0c27e678fe 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -747,6 +747,7 @@ def get_course_data(course_key_str, fields): Arguments: course_key_str: key for the course about which we are retrieving information. + fields (List, string): The given fields that you want to retrieve from API response. Returns: dict with details about specified course. diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/__init__.py b/openedx/core/djangoapps/user_api/learner_skill_levels/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/api.py b/openedx/core/djangoapps/user_api/learner_skill_levels/api.py new file mode 100644 index 000000000000..ea1714cb6a36 --- /dev/null +++ b/openedx/core/djangoapps/user_api/learner_skill_levels/api.py @@ -0,0 +1,31 @@ +""" +APIs for learner skill levels. +""" +from .utils import get_skills_score, calculate_user_skill_score, generate_skill_score_mapping + + +def get_learner_skill_levels(user, top_categories): + """ + Evaluates learner's skill levels in the given job category. Only considers skills for the categories + and not their sub-categories. + + Params: + user: user for each score is being calculated. + top_categories (List, string): A list of fields (as strings) of job categories and their skills. + Returns: + top_categories: Categories with scores appended to skills. + """ + + # get a skill to score mapping for every course user has passed + skill_score_mapping = generate_skill_score_mapping(user) + for skill_category in top_categories: + category_skills = skill_category['skills'] + get_skills_score(category_skills, skill_score_mapping) + skill_category['user_score'] = calculate_user_skill_score(category_skills) + skill_category['edx_average_score'] = None + sub_categories = skill_category['skills_subcategories'] + for sub_category in sub_categories: + subcategory_skills = sub_category['skills'] + get_skills_score(subcategory_skills, skill_score_mapping) + + return top_categories diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/constants.py b/openedx/core/djangoapps/user_api/learner_skill_levels/constants.py new file mode 100644 index 000000000000..225dc1faba14 --- /dev/null +++ b/openedx/core/djangoapps/user_api/learner_skill_levels/constants.py @@ -0,0 +1,8 @@ +""" +Constants for learner skill levels app. +""" +LEVEL_TYPE_SCORE_MAPPING = { + 'Introductory': 1, + 'Intermediate': 2, + 'Advanced': 3 +} diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/tests/__init__.py b/openedx/core/djangoapps/user_api/learner_skill_levels/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/tests/test_utils.py b/openedx/core/djangoapps/user_api/learner_skill_levels/tests/test_utils.py new file mode 100644 index 000000000000..a4b02d766624 --- /dev/null +++ b/openedx/core/djangoapps/user_api/learner_skill_levels/tests/test_utils.py @@ -0,0 +1,216 @@ +""" Unit tests for Learner Skill Levels utilities. """ + +import ddt +from collections import defaultdict +from unittest import mock + +from rest_framework.test import APIClient + +from common.djangoapps.student.tests.factories import UserFactory + +from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin +from openedx.core.djangoapps.user_api.learner_skill_levels.utils import ( + calculate_user_skill_score, + generate_skill_score_mapping, + get_base_url, + get_job_holder_usernames, + get_skills_score, + get_top_skill_categories_for_job, + update_category_user_scores_map, + update_edx_average_score, +) +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + +from .testutils import ( + DUMMY_CATEGORIES_RESPONSE, + DUMMY_CATEGORIES_WITH_SCORES, + DUMMY_USERNAMES_RESPONSE, + DUMMY_COURSE_DATA_RESPONSE, + DUMMY_USER_SCORES_MAP, +) + + +@ddt.ddt +class LearnerSkillLevelsUtilsTests(SharedModuleStoreTestCase, CatalogIntegrationMixin): + """ + Test LearnerSkillLevel utilities. + """ + SERVICE_USERNAME = 'catalog_service_username' + + def setUp(self): + """ + Unit tests setup. + """ + super().setUp() + + self.client = APIClient() + self.service_user = UserFactory(username=self.SERVICE_USERNAME) + self.catalog_integration = self.create_catalog_integration() + + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_course_run_ids') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_course_data') + def test_generate_skill_score_mapping( + self, + mock_get_course_data, + mock_get_course_run_ids, + ): + """ + Test that skill-score mapping is returned in correct format. + """ + user = UserFactory(username='edX') + mock_get_course_run_ids.return_value = ['AWS+OTP-AWSD12'] + mock_get_course_data.return_value = DUMMY_COURSE_DATA_RESPONSE + result = generate_skill_score_mapping(user) + expected_response = {"python": 3, "MongoDB": 3, "Data Science": 3} + assert result == expected_response + + @ddt.data( + ([], 0.0), + ( + [ + {"id": 1, "name": "Financial Management", "score": None}, + {"id": 2, "name": "Fintech", "score": None}, + ], 0.0 + ), + ( + [ + {"id": 1, "name": "Financial Management", "score": None}, + {"id": 2, "name": "Fintech", "score": None}, + ], 0.0 + ), + ( + [ + {"id": 1, "name": "Financial Management", "score": 3}, + {"id": 2, "name": "Fintech", "score": 2}, + ], 0.8 + ), + ) + @ddt.unpack + def test_calculate_user_skill_score(self, skills_with_score, expected): + """ + Test that skill-score mapping is returned in correct format. + """ + + result = calculate_user_skill_score(skills_with_score) + assert result == expected + + @ddt.data( + ([], {"Financial Management": 1, "Fintech": 3}, []), + ( + [ + {"id": 1, "name": "Financial Management"}, + {"id": 2, "name": "Fintech"}, + ], + { + "Financial Management": 1, + "Fintech": 3 + }, + [ + {"id": 1, "name": "Financial Management", "score": 1}, + {"id": 2, "name": "Fintech", "score": 3}, + ], + ), + ( + [ + {"id": 1, "name": "Financial Management"}, + {"id": 2, "name": "Fintech"}, + ], + {}, + [ + {"id": 1, "name": "Financial Management", "score": None}, + {"id": 2, "name": "Fintech", "score": None}, + ], + ), + ( + [ + {"id": 1, "name": "Financial Management"}, + {"id": 2, "name": "Fintech"}, + ], + { + "Python": 1, + "AI": 3 + }, + [ + {"id": 1, "name": "Financial Management", "score": None}, + {"id": 2, "name": "Fintech", "score": None}, + ], + ), + ) + @ddt.unpack + def test_get_skills_score(self, skills, learner_skill_score, expected): + """ + Test that skill-score mapping is returned in correct format. + """ + get_skills_score(skills, learner_skill_score) + assert skills == expected + + def test_update_category_user_scores_map(self): + """ + Test that skill-score mapping is returned in correct format. + """ + category_user_scores_map = defaultdict(list) + update_category_user_scores_map(DUMMY_CATEGORIES_WITH_SCORES["skill_categories"], category_user_scores_map) + expected = {"Information Technology": [0.8], "Finance": [0.3]} + assert category_user_scores_map == expected + + def test_update_edx_average_score(self): + """ + Test that skill-score mapping is returned in correct format. + """ + update_edx_average_score(DUMMY_CATEGORIES_WITH_SCORES["skill_categories"], DUMMY_USER_SCORES_MAP) + assert DUMMY_CATEGORIES_WITH_SCORES["skill_categories"][0]["edx_average_score"] == 0.4 + assert DUMMY_CATEGORIES_WITH_SCORES["skill_categories"][1]["edx_average_score"] == 0.5 + + @ddt.data( + ("http://localhost:18000/api/", "http://localhost:18000"), + ("http://localhost:18000/", "http://localhost:18000"), + ) + @ddt.unpack + def test_get_base_url(self, source_url, expected): + """ + Test that base url is returned correctly. + """ + actual = get_base_url(source_url) + assert actual == expected + + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_catalog_api_client') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_catalog_api_base_url') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_api_data') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.check_catalog_integration_and_get_user') + def test_get_top_skill_categories_for_job( + self, + mock_check_catalog_integration_and_get_user, + mock_get_api_data, + mock_get_catalog_api_base_url, + mock_get_catalog_api_client + ): + """ + Test that get_top_skill_categories_for_job returns jobs categories. + """ + mock_check_catalog_integration_and_get_user.return_value = self.service_user, self.catalog_integration + mock_get_api_data.return_value = DUMMY_CATEGORIES_RESPONSE + mock_get_catalog_api_base_url.return_value = 'localhost:18381/api' + mock_get_catalog_api_client.return_value = self.client + result = get_top_skill_categories_for_job(1) + assert result == DUMMY_CATEGORIES_RESPONSE + + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_catalog_api_client') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_catalog_api_base_url') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.get_api_data') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.utils.check_catalog_integration_and_get_user') + def test_get_job_holder_usernames( + self, + mock_check_catalog_integration_and_get_user, + mock_get_api_data, + mock_get_catalog_api_base_url, + mock_get_catalog_api_client + ): + """ + Test that test_get_job_holder_usernames returns usernames. + """ + mock_check_catalog_integration_and_get_user.return_value = self.service_user, self.catalog_integration + mock_get_api_data.return_value = DUMMY_USERNAMES_RESPONSE + mock_get_catalog_api_base_url.return_value = 'localhost:18381/api' + mock_get_catalog_api_client.return_value = self.client + result = get_job_holder_usernames(1) + assert result == DUMMY_USERNAMES_RESPONSE diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/tests/test_views.py b/openedx/core/djangoapps/user_api/learner_skill_levels/tests/test_views.py new file mode 100644 index 000000000000..369f32787f90 --- /dev/null +++ b/openedx/core/djangoapps/user_api/learner_skill_levels/tests/test_views.py @@ -0,0 +1,119 @@ +""" +Test cases for LearnerSkillLevelsView. +""" + +from unittest import mock + +from django.urls import reverse +from rest_framework.test import APIClient, APITestCase + +from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory + +from .testutils import DUMMY_CATEGORIES_RESPONSE, DUMMY_USERNAMES_RESPONSE + + +class LearnerSkillLevelsViewTests(APITestCase): + """ + The tests for LearnerSkillLevelsView. + """ + + def setUp(self): + super().setUp() + + self.client = APIClient() + self.user = UserFactory.create(password=TEST_PASSWORD) + self.url = reverse('learner_skill_level', kwargs={'job_id': '1'}) + + for username in DUMMY_USERNAMES_RESPONSE['usernames']: + UserFactory(username=username) + + def test_unauthorized_get_endpoint(self): + """ + Test that endpoint is only accessible to authorized user. + """ + response = self.client.get(self.url) + assert response.status_code == 401 + + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.views.get_top_skill_categories_for_job') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.views.get_job_holder_usernames') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.api.generate_skill_score_mapping') + def test_get_endpoint( + self, + mock_generate_skill_score_mapping, + mock_get_job_holder_usernames, + mock_get_top_skill_categories_for_job + ): + """ + Test that response if returned with correct scores appended. + """ + mock_get_top_skill_categories_for_job.return_value = DUMMY_CATEGORIES_RESPONSE + mock_get_job_holder_usernames.return_value = DUMMY_USERNAMES_RESPONSE + mock_generate_skill_score_mapping.return_value = {'Technology Roadmap': 2, 'Python': 3} + + self.client.login(username=self.user.username, password=TEST_PASSWORD) + response = self.client.get(self.url) + assert response.status_code == 200 + # check if the response is mutated and scores are appended for skills + # for when some skills are learned by user in a category, check if user_score and avg score is appended + assert response.data['skill_categories'][0]['user_score'] == 0.8 + assert response.data['skill_categories'][0]['edx_average_score'] == 0.8 + + # for when no skill is learned by user in a category, check if user_score and avg score is appended + assert response.data['skill_categories'][1]['user_score'] == 0.0 + assert response.data['skill_categories'][1]['edx_average_score'] == 0.0 + + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.views.get_top_skill_categories_for_job') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.views.get_job_holder_usernames') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.api.generate_skill_score_mapping') + def test_get_with_less_than_5_users( + self, + mock_generate_skill_score_mapping, + mock_get_job_holder_usernames, + mock_get_top_skill_categories_for_job + ): + """ + Test that average value is None when users are less than 5. + """ + mock_get_top_skill_categories_for_job.return_value = DUMMY_CATEGORIES_RESPONSE + mock_get_job_holder_usernames.return_value = {"usernames": ['user1', 'user2']} + mock_generate_skill_score_mapping.return_value = {'Technology Roadmap': 2, 'Python': 3} + + self.client.login(username=self.user.username, password=TEST_PASSWORD) + response = self.client.get(self.url) + assert response.status_code == 200 + # check if the response is mutated and scores are appended for skills + # for when some skills are learned by user in a category, check if user_score and avg score is appended + assert response.data['skill_categories'][0]['user_score'] == 0.8 + assert response.data['skill_categories'][0]['edx_average_score'] is None + + # for when no skill is learned by user in a category, check if user_score and avg score is appended + assert response.data['skill_categories'][1]['user_score'] == 0.0 + assert response.data['skill_categories'][1]['edx_average_score'] is None + + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.views.get_top_skill_categories_for_job') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.views.get_job_holder_usernames') + @mock.patch('openedx.core.djangoapps.user_api.learner_skill_levels.api.generate_skill_score_mapping') + def test_get_no_skills_learned( + self, + mock_generate_skill_score_mapping, + mock_get_job_holder_usernames, + mock_get_top_skill_categories_for_job + ): + """ + Test that score is 0.0 when no skills are learned by a user. + """ + mock_get_top_skill_categories_for_job.return_value = DUMMY_CATEGORIES_RESPONSE + mock_get_job_holder_usernames.return_value = DUMMY_USERNAMES_RESPONSE + mock_generate_skill_score_mapping.return_value = {} + + self.client.login(username=self.user.username, password=TEST_PASSWORD) + response = self.client.get(self.url) + assert response.status_code == 200 + # check if the response is mutated and scores are appended for skills + # for when some skills are learned by user in a category, check if user_score and avg score is appended + assert response.data['skill_categories'][0]['user_score'] == 0.0 + assert response.data['skill_categories'][0]['edx_average_score'] == 0.0 + + # for when no skill is learned by user in a category, check if user_score and avg score is appended + assert response.data['skill_categories'][1]['user_score'] == 0.0 + assert response.data['skill_categories'][1]['edx_average_score'] == 0.0 diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/tests/testutils.py b/openedx/core/djangoapps/user_api/learner_skill_levels/tests/testutils.py new file mode 100644 index 000000000000..44a46f5cf439 --- /dev/null +++ b/openedx/core/djangoapps/user_api/learner_skill_levels/tests/testutils.py @@ -0,0 +1,110 @@ +""" +Utilities for unit tests of learner skill levels. +""" + +DUMMY_CATEGORIES_RESPONSE = { + "job": "Digital Product Manager", + "skill_categories": [ + { + "name": "Information Technology", + "id": 1, + "skills": [ + {"id": 3, "name": "Technology Roadmap"}, + {"id": 12, "name": "Python"}, + {"id": 2, "name": "MongoDB"} + ], + "skills_subcategories": [ + { + "id": 1, + "name": "Databases", + "skills": [ + {"id": 1, "name": "Query Languages"}, + {"id": 2, "name": "MongoDB"}, + ] + }, + { + "id": 2, + "name": "IT Management", + "skills": [ + {"id": 3, "name": "Technology Roadmap"}, + ] + }, + ] + }, + { + "name": "Finance", + "id": 2, + "skills": [ + {"id": 4, "name": "Accounting"}, + {"id": 5, "name": "TQM"}, + ], + "skills_subcategories": [ + { + "id": 3, + "name": "Auditing", + "skills": [ + {"id": 4, "name": "Accounting"}, + {"id": 5, "name": "TQM"}, + ] + }, + { + "id": 4, + "name": "Management", + "skills": [ + {"id": 6, "name": "Financial Management"}, + ] + }, + ] + }, + ] +} + +DUMMY_CATEGORIES_WITH_SCORES = { + "job": "Digital Product Manager", + "skill_categories": [ + { + "name": "Information Technology", + "id": 1, + "skills": [ + {"id": 3, "name": "Technology Roadmap", "score": 1}, + {"id": 12, "name": "Python", "score": 2}, + {"id": 2, "name": "MongoDB", "score": 3} + ], + "user_score": 0.8, + }, + { + "name": "Finance", + "id": 2, + "skills": [ + {"id": 1, "name": "Query Languages", "score": 1}, + {"id": 4, "name": "System Design", "score": 2}, + ], + "user_score": 0.3, + }, + ] +} + +DUMMY_USER_SCORES_MAP = { + "Information Technology": [0.1, 0.3, 0.5, 0.7], + "Finance": [0.2, 0.4, 0.6, 0.8] + +} + +DUMMY_USERNAMES_RESPONSE = { + "usernames": [ + 'test_user_1', + 'test_user_2', + 'test_user_3', + 'test_user_4', + 'test_user_5', + 'test_user_6', + ] +} + +DUMMY_COURSE_DATA_RESPONSE = { + "key": "AWS+OTP-AWSD12", + "uuid": "fe1a9ad4-a452-45cd-80e5-9babd3d43f96", + "title": "Demonstration Course", + "level_type": 'Advanced', + "skill_names": ["python", "MongoDB", "Data Science"] +} diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/utils.py b/openedx/core/djangoapps/user_api/learner_skill_levels/utils.py new file mode 100644 index 000000000000..ef2b4ea6e821 --- /dev/null +++ b/openedx/core/djangoapps/user_api/learner_skill_levels/utils.py @@ -0,0 +1,172 @@ +""" +Utilities for learner_skill_levels. +""" +from urllib.parse import urlparse + +from lms.djangoapps.grades.models import PersistentCourseGrade # lint-amnesty, pylint: disable=unused-import +from openedx.core.djangoapps.catalog.utils import ( + get_catalog_api_client, + check_catalog_integration_and_get_user, + get_catalog_api_base_url, + +) +from openedx.core.djangoapps.catalog.utils import get_course_data +from openedx.core.lib.edx_api_utils import get_api_data + +from .constants import LEVEL_TYPE_SCORE_MAPPING + + +def get_course_run_ids(user): + """ + Returns all the course run ids of the courses that user has passed from PersistentCourseGrade model. + """ + return list( + PersistentCourseGrade.objects.filter( + user_id=user.id, + passed_timestamp__isnull=False + ).values_list('course_id', flat=True) + ) + + +def generate_skill_score_mapping(user): + """ + Generates a skill to score mapping for all the skills user has learner so far in passed courses. + """ + # get course_run_ids of all courses the user has passed + course_run_ids = get_course_run_ids(user) + + skill_score_mapping = {} + for course_run_id in course_run_ids: + course_data = get_course_data(course_run_id, ['skill_names', 'level_type']) + skill_names = course_data['skill_names'] + level_type = course_data['level_type'].capitalize() + + # if a level_type is None for a course, we should skip that course. + if level_type: + score = LEVEL_TYPE_SCORE_MAPPING[level_type] + for skill in skill_names: + if skill in skill_score_mapping: + # assign scores b/w 1-3 based on level type + # assign the larger score if skill is repeated in 2 courses + skill_score_mapping[skill] = max(score, skill_score_mapping[skill]) + else: + skill_score_mapping.update({skill: score}) + return skill_score_mapping + + +def calculate_user_skill_score(skills_with_score): + """ + Calculates user skill score to see where the user falls in a certain job category. + """ + # generate a dict with skill name as key and score as value + # take only those skills that user has learned. + + if not skills_with_score: + return 0.0 + + skills_score_dict = { + item['name']: item['score'] + for item in skills_with_score + if item['score'] is not None + } + sum_of_skills = sum(skills_score_dict.values()) + skills_count = len(skills_score_dict) + if not skills_count: + return 0.0 + # sum of skills score in the category/ 3*no. of skills in category + return round(sum_of_skills / (3 * skills_count), 1) + + +def get_skills_score(skills, learner_skill_score): + """ + Takes each skill item in list and appends its score to it. + For a skill that doesn't exist in learner's skills set, appends None as score. + """ + for skill in skills: + skill['score'] = learner_skill_score.get(skill['name']) + + +def update_category_user_scores_map(categories, category_user_scores_map): + """ + Appends user's scores for each category in the dict. + """ + for category in categories: + category_user_scores_map[category['name']].append(category['user_score']) + + +def update_edx_average_score(categories, user_score_mapping): + """ + Calculates average score for each category and appends it. + """ + for category in categories: + category_scores = user_score_mapping[category['name']] + sum_score = sum(category_scores, 0.0) + average_score = round(sum_score / len(category_scores), 1) + category['edx_average_score'] = average_score + + +def get_base_url(url): + """ + Returns the base url for any given url. + """ + if url: + parsed = urlparse(url) + return f'{parsed.scheme}://{parsed.netloc}' + + +def get_top_skill_categories_for_job(job_id): + """ + Retrieve top categories for the job with the given job_id. + + Arguments: + job_id (int): id of the job about which we are retrieving information. + + Returns: + dict with top 5 categories of specified job. + """ + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Skill Categories') + if user: + api_client = get_catalog_api_client(user) + root_url = get_catalog_api_base_url() + base_api_url = get_base_url(root_url) + resource = '/taxonomy/api/v1/job-top-subcategories' + cache_key = f'{catalog_integration.CACHE_KEY}.job-categories.{job_id}' + data = get_api_data( + catalog_integration, + resource=resource, + resource_id=job_id, + api_client=api_client, + base_api_url=base_api_url, + cache_key=cache_key if catalog_integration.is_cache_enabled else None, + ) + if data: + return data + + +def get_job_holder_usernames(job_id): + """ + Retrieve usernames of users who have the same job as given job_id. + + Arguments: + job_id (int): id of the job for which we are retrieving usernames. + + Returns: + list with oldest 100 users' usernames that exist in our system. + """ + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Job Holder Usernames') + if user: + api_client = get_catalog_api_client(user) + root_url = get_catalog_api_base_url() + base_api_url = get_base_url(root_url) + resource = '/taxonomy/api/v1/job-holder-usernames' + cache_key = f'{catalog_integration.CACHE_KEY}.job-holder-usernames.{job_id}' + data = get_api_data( + catalog_integration, + resource=resource, + resource_id=job_id, + api_client=api_client, + base_api_url=base_api_url, + cache_key=cache_key if catalog_integration.is_cache_enabled else None, + ) + if data: + return data diff --git a/openedx/core/djangoapps/user_api/learner_skill_levels/views.py b/openedx/core/djangoapps/user_api/learner_skill_levels/views.py new file mode 100644 index 000000000000..689a8dfadf5e --- /dev/null +++ b/openedx/core/djangoapps/user_api/learner_skill_levels/views.py @@ -0,0 +1,124 @@ +""" +Views for learner_skill_levels. +""" +from collections import defaultdict +from copy import deepcopy + +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.views import APIView + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser + +from .api import get_learner_skill_levels +from .utils import get_top_skill_categories_for_job, get_job_holder_usernames, update_category_user_scores_map, \ + update_edx_average_score + + +class LearnerSkillLevelsView(APIView): + """ + **Use Cases** + + Returns top 5 job categories for the given job. Checks which skill the user has learned via courses + and assign scores to each skill in category. Also takes first 100 users in our system to calculate + average score for each category. + + **Request format** + + GET /api/user/v1/skill_level/{job_id}/ + + **Response Values for GET** + + If the specified job_id doesn't exist, an HTTP + 404 "Not Found" response is returned. + + If a logged in user makes a request with an existing job, an HTTP 200 + "OK" response is returned that contains a JSON string. + + **Example Request** + + GET /api/user/v1/skill_level/1/ + + **Example Response** + + { + "job": "Digital Product Manager", + "skill_categories": [ + { + "name": "Information Technology", + "id": 1, + "skills": [ + {"id": 2, "name": "Query Languages", "score": 1}, + {"id": 3, "name": "MongoDB", "score": 3}, + ] + "user_score": 0.4, // request user's score + "edx_average_score": 0.7, + "skills_subcategories": [ + { + "id": 1, + "name": "Databases", + "skills": [ + {"id": 2, "name": "Query Languages", "score": 1}, + {"id": 3, "name": "MongoDB", "score": None}, + ] + }, + { + "id": 2, + "name": "IT Management", + "skills": [ + {"id": 1, "name": "Technology Roadmap", "score": 2}, + ] + }, + // here remaining job related skills subcategories + ] + }, + + // Here more 4 skill categories + ] + } + """ + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser + ) + permission_classes = (permissions.IsAuthenticated, ) + + def get(self, request, job_id): + """ + GET /api/user/v1/skill_level/{job_id}/ + """ + # get top categories for the given job + job_skill_categories = get_top_skill_categories_for_job(job_id) + + # assign scores for every skill request user has learned + top_categories = deepcopy(job_skill_categories['skill_categories']) + user_category_scores = get_learner_skill_levels( + user=request.user, + top_categories=top_categories, + ) + + # repeat the same logic for 100 job holder users in our system + job_holder_usernames = get_job_holder_usernames(job_id) + users = User.objects.filter(username__in=job_holder_usernames['usernames']) + + # edx_avg_score should only be calculated if users count is greater than 5, else skip it. + if len(users) > 5: + # To save all the users' scores against every category to calculate average score + category_user_scores_map = defaultdict(list) + + for user in users: + categories = deepcopy(job_skill_categories['skill_categories']) + categories_with_scores = get_learner_skill_levels( + user=user, + top_categories=categories, + ) + update_category_user_scores_map(categories_with_scores, category_user_scores_map) + + update_edx_average_score(user_category_scores, category_user_scores_map) + + job_skill_categories['skill_categories'] = user_category_scores + return Response(job_skill_categories) diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index 396ffcb74b8b..e93e7119ecea 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -19,6 +19,7 @@ NameChangeView, UsernameReplacementView, CancelAccountRetirementStatusView ) +from .learner_skill_levels.views import LearnerSkillLevelsView from . import views as user_api_views from .models import UserPreference from .preferences.views import PreferencesDetailView, PreferencesView @@ -196,6 +197,11 @@ PreferencesDetailView.as_view(), name='preferences_detail_api' ), + re_path( + r'^v1/skill_level/(?P[0-9]+)/$', + LearnerSkillLevelsView.as_view(), + name="learner_skill_level" + ), # Moved from user_api/legacy_urls.py path('v1/', include(USER_API_ROUTER.urls)),