Repository: oppia/oppia

Issue:
Add backend restrictions on number of skill IDs when requesting for questions 
When the list of skill IDs has a length longer than 20, raise an exception at all relevant controllers and domain. In the controller, add a filter to select relatively low mastery level skills.

Add backend restrictions on number of skill IDs when requesting for questions 
When the list of skill IDs has a length longer than 20, raise an exception at all relevant controllers and domain. In the controller, add a filter to select relatively low mastery level skills.



Discussion:

Resolving patch:
diff --git a/core/controllers/reader.py b/core/controllers/reader.py
--- a/core/controllers/reader.py
+++ b/core/controllers/reader.py
@@ -39,6 +39,7 @@
 from core.domain import rating_services
 from core.domain import recommendations_services
 from core.domain import rights_manager
+from core.domain import skill_services
 from core.domain import stats_domain
 from core.domain import stats_services
 from core.domain import story_fetchers
@@ -988,6 +989,10 @@ def get(self):
                 'fetch_by_difficulty must be true or false')
         fetch_by_difficulty = (fetch_by_difficulty_value == 'true')
 
+        if len(skill_ids) > feconf.MAX_NUMBER_OF_SKILL_IDS:
+            skill_ids = skill_services.filter_skills_by_mastery(
+                self.user_id, skill_ids)
+
         questions = (
             question_services.get_questions_by_skill_ids(
                 int(question_count), skill_ids, fetch_by_difficulty)
diff --git a/core/domain/skill_services.py b/core/domain/skill_services.py
--- a/core/domain/skill_services.py
+++ b/core/domain/skill_services.py
@@ -867,3 +867,31 @@ def skill_has_associated_questions(skill_id):
         question_models.QuestionSkillLinkModel.get_all_question_ids_linked_to_skill_id( # pylint: disable=line-too-long
             skill_id))
     return len(question_ids) > 0
+
+
+def filter_skills_by_mastery(user_id, skill_ids):
+    """Given a list of skill_ids, it returns a list of
+    feconf.MAX_NUMBER_OF_SKILL_IDS skill_ids in which the user has
+    the least mastery.(Please note that python 2.7 considers the None
+    type smaller than any value, so None types will be returned first)
+
+    Args:
+        user_id: str. The unique user ID of the user.
+        skill_ids: list(str). The skill_ids that are to be filtered.
+
+    Returns:
+        list(str). A list of the filtered skill_ids.
+    """
+    degrees_of_mastery = get_multi_user_skill_mastery(user_id, skill_ids)
+
+    sorted_skill_ids = sorted(
+        degrees_of_mastery, key=degrees_of_mastery.get)
+
+    filtered_skill_ids = sorted_skill_ids[:feconf.MAX_NUMBER_OF_SKILL_IDS]
+
+    # Arranges the skill_ids in the order as it was received.
+    arranged_filtered_skill_ids = []
+    for i in python_utils.RANGE(len(skill_ids)):
+        if skill_ids[i] in filtered_skill_ids:
+            arranged_filtered_skill_ids.append(skill_ids[i])
+    return arranged_filtered_skill_ids


Test patch:
diff --git a/core/controllers/reader_test.py b/core/controllers/reader_test.py
--- a/core/controllers/reader_test.py
+++ b/core/controllers/reader_test.py
@@ -42,6 +42,7 @@
 from core.platform.taskqueue import gae_taskqueue_services as taskqueue_services
 from core.tests import test_utils
 import feconf
+import python_utils
 
 (classifier_models, stats_models) = models.Registry.import_models(
     [models.NAMES.classifier, models.NAMES.statistics])
@@ -346,9 +347,15 @@ def test_get_exploration_pretests(self):
 class QuestionsUnitTest(test_utils.GenericTestBase):
     """Test the handler for fetching questions."""
 
+    USER_EMAIL = 'user@example.com'
+    USER_USERNAME = 'user'
+
     def setUp(self):
         """Before each individual test, initialize data."""
         super(QuestionsUnitTest, self).setUp()
+        self.signup(self.USER_EMAIL, self.USER_USERNAME)
+        self.user_id = self.get_user_id_from_email(self.USER_EMAIL)
+
         self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME)
         self.editor_id = self.get_user_id_from_email(self.EDITOR_EMAIL)
 
@@ -401,6 +408,44 @@ def test_multiple_skill_id_returns_questions(self):
         self.assertItemsEqual(
             [self.question_id, self.question_id_2, question_id_3], question_ids)
 
+    def test_filter_multiple_skill_id_return_questions(self):
+        self.login(self.USER_EMAIL)
+        skill_ids_for_url = ''
+
+        # Create multiple skills, questions and skill links.
+        for _ in python_utils.RANGE(
+                feconf.MAX_QUESTIONS_FETCHABLE_AT_ONE_TIME):
+            skill_id = skill_services.get_new_skill_id()
+            skill_ids_for_url = skill_ids_for_url + skill_id + ','
+            self.save_new_skill(skill_id, 'user', description='Description')
+            question_id = question_services.get_new_question_id()
+            self.save_new_question(
+                question_id, 'user',
+                self._create_valid_question_data('ABC'), [skill_id])
+            question_services.create_new_question_skill_link(
+                self.editor_id, question_id, skill_id, 0.5)
+
+        # Create additional skills with user skill mastery > 0.0,
+        # so that these are filtered out correctly.
+        for _ in python_utils.RANGE(5):
+            skill_id = skill_services.get_new_skill_id()
+            skill_ids_for_url = skill_ids_for_url + skill_id + ','
+            self.save_new_skill(skill_id, 'user', description='Description')
+            skill_services.create_user_skill_mastery(
+                self.user_id, skill_id, 0.5)
+
+        # Removing the last comma of the string.
+        skill_ids_for_url = skill_ids_for_url[:-1]
+
+        url = '%s?question_count=%s&skill_ids=%s&fetch_by_difficulty=%s' % (
+            feconf.QUESTIONS_URL_PREFIX,
+            python_utils.convert_to_bytes(
+                feconf.MAX_QUESTIONS_FETCHABLE_AT_ONE_TIME),
+            skill_ids_for_url, 'true')
+        json_response = self.get_json(url)
+        self.assertEqual(len(json_response['question_dicts']),
+                         feconf.MAX_QUESTIONS_FETCHABLE_AT_ONE_TIME)
+
     def test_invalid_skill_id_returns_no_questions(self):
         # Call the handler.
         url = '%s?question_count=%s&skill_ids=%s&fetch_by_difficulty=%s' % (
diff --git a/core/domain/skill_services_test.py b/core/domain/skill_services_test.py
--- a/core/domain/skill_services_test.py
+++ b/core/domain/skill_services_test.py
@@ -18,6 +18,7 @@
 from __future__ import unicode_literals  # pylint: disable=import-only-modules
 
 import logging
+import random
 
 from constants import constants
 from core.domain import skill_domain
@@ -960,6 +961,51 @@ def test_create_multi_user_skill_mastery(self):
         self.assertEqual(
             degrees_of_mastery, {skill_id_4: 0.3, skill_id_5: 0.5})
 
+    def test_filter_skills_by_mastery(self):
+        # Create feconf.MAX_NUMBER_OF_SKILL_IDS + 3 skill_ids
+        # to test two things:
+        #   1. The skill_ids should be sorted.
+        #   2. The filtered skill_ids should be feconf.MAX_NUMBER_OF_SKILL_IDS
+        # in number.
+
+        # List of mastery values (float values between 0.0 and 1.0)
+        masteries = [self.DEGREE_OF_MASTERY_1, self.DEGREE_OF_MASTERY_2, None]
+
+        # Creating feconf.MAX_NUMBER_OF_SKILL_IDS additional user skill
+        # masteries.
+        for _ in python_utils.RANGE(feconf.MAX_NUMBER_OF_SKILL_IDS):
+            skill_id = skill_services.get_new_skill_id()
+            mastery = random.random()
+            masteries.append(mastery)
+            skill_services.create_user_skill_mastery(
+                self.USER_ID, skill_id, mastery)
+            self.SKILL_IDS.append(skill_id)
+
+        # Sorting the masteries, which should represent the masteries of the
+        # skill_ids that are finally returned.
+        masteries.sort()
+        degrees_of_masteries = skill_services.get_multi_user_skill_mastery(
+            self.USER_ID, self.SKILL_IDS)
+        arranged_filtered_skill_ids = skill_services.filter_skills_by_mastery(
+            self.USER_ID, self.SKILL_IDS)
+
+        self.assertEqual(
+            len(arranged_filtered_skill_ids), feconf.MAX_NUMBER_OF_SKILL_IDS)
+
+        # Assert that all the returned skill_ids are a part of the first
+        # feconf.MAX_NUMBER_OF_SKILL_IDS sorted skill_ids.
+        for i in python_utils.RANGE(feconf.MAX_NUMBER_OF_SKILL_IDS):
+            self.assertIn(
+                degrees_of_masteries[arranged_filtered_skill_ids[i]],
+                masteries[:feconf.MAX_NUMBER_OF_SKILL_IDS])
+
+        # Testing the arrangement.
+        excluded_skill_ids = list(set(self.SKILL_IDS) - set(
+            arranged_filtered_skill_ids))
+        for skill_id in excluded_skill_ids:
+            self.SKILL_IDS.remove(skill_id)
+        self.assertEqual(arranged_filtered_skill_ids, self.SKILL_IDS)
+
 
 # TODO(lilithxxx): Remove this mock class and tests for the mock skill
 # migrations once the actual functions are implemented.