Repository: oppia/oppia

Issue:
Unable to search for skills in the Concept card RTE modal (question editor UI)
<!--
  - Thanks for taking the time to report a bug in the Oppia project.
  - Before filing a new issue, please do a quick search to check that it hasn't
  - already been filed on the [issue tracker](https://github.com/oppia/oppia/issues)._
  -->

**Describe the bug**
Unable to search for skills in the Concept card RTE modal (question editor UI)

**To Reproduce**
Steps to reproduce the behavior:
 1. Create a skill
 2. Try to add a concept card link in the review material, misconceptions, rubric or in any field in the question editor modal.
 3. Observe that the "search for skill" does not work.
 4. See error in the console.

**Observed behavior**
![unable search for skill when attaching a concept card](https://user-images.githubusercontent.com/11008603/81508501-0927dd00-9322-11ea-846c-6fa88d501b6e.png)


**Expected behavior**
It should fetch skills correctly and not error in the console.

**Desktop (please complete the following information; delete this section if the issue does not arise on desktop):**
 - OS: Chrome
 - Browser: 79



Discussion:

Resolving patch:
diff --git a/core/controllers/skill_editor.py b/core/controllers/skill_editor.py
--- a/core/controllers/skill_editor.py
+++ b/core/controllers/skill_editor.py
@@ -181,8 +181,13 @@ def put(self, skill_id):
     def delete(self, skill_id):
         """Handles Delete requests."""
         skill_domain.Skill.require_valid_skill_id(skill_id)
+        skill_ids_assigned_to_some_topic = (
+            topic_services.get_all_skill_ids_assigned_to_some_topic())
+        if skill_id in skill_ids_assigned_to_some_topic:
+            raise self.InvalidInputException(
+                'Cannot delete skill that is assigned to a topic.')
         if skill_services.skill_has_associated_questions(skill_id):
-            raise Exception(
+            raise self.InvalidInputException(
                 'Please delete all questions associated with this skill '
                 'first.')
 
diff --git a/core/domain/topic_jobs_one_off.py b/core/domain/topic_jobs_one_off.py
--- a/core/domain/topic_jobs_one_off.py
+++ b/core/domain/topic_jobs_one_off.py
@@ -28,8 +28,10 @@
 from core.domain import topic_services
 from core.platform import models
 import feconf
+import python_utils
 
-(topic_models,) = models.Registry.import_models([models.NAMES.topic])
+(skill_models, topic_models) = models.Registry.import_models(
+    [models.NAMES.skill, models.NAMES.topic])
 
 
 class TopicMigrationOneOffJob(jobs.BaseMapReduceOneOffJobManager):
@@ -92,3 +94,74 @@ def reduce(key, values):
                 sum(ast.literal_eval(v) for v in values))])
         else:
             yield (key, values)
+
+
+class RemoveDeletedSkillsFromTopicOneOffJob(
+        jobs.BaseMapReduceOneOffJobManager):
+    """One-off job to remove deleted uncategorized skills linked to a topic."""
+
+    _DELETED_KEY = 'topic_deleted'
+    _PROCESSED_KEY = 'topic_processed'
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        return [topic_models.TopicModel]
+
+    @staticmethod
+    def map(item):
+        if item.deleted:
+            yield (RemoveDeletedSkillsFromTopicOneOffJob._DELETED_KEY, 1)
+            return
+
+        # Note: the read will bring the topic up to the newest version.
+        topic = topic_fetchers.get_topic_by_id(item.id)
+        skill_ids_to_be_removed_from_subtopic = []
+        all_skill_ids_to_be_removed = []
+        commit_cmds = []
+
+        # This block of code removes deleted skills from subtopics, but keeps
+        # them in the topic.
+        for subtopic in topic.get_all_subtopics():
+            subtopic_skill_models = skill_models.SkillModel.get_multi(
+                subtopic['skill_ids'])
+            for skill_id, skill_model in python_utils.ZIP(
+                    subtopic['skill_ids'], subtopic_skill_models):
+                if skill_model is None:
+                    commit_cmds.append(topic_domain.TopicChange({
+                        'cmd': topic_domain.CMD_REMOVE_SKILL_ID_FROM_SUBTOPIC,
+                        'skill_id': skill_id,
+                        'subtopic_id': subtopic['id']
+                    }))
+                    skill_ids_to_be_removed_from_subtopic.append(skill_id)
+
+        all_skill_models = skill_models.SkillModel.get_multi(
+            topic.get_all_skill_ids())
+
+        # This block of code removes all deleted skills from topics.
+        for skill_id, skill_model in python_utils.ZIP(
+                topic.get_all_skill_ids(), all_skill_models):
+            if skill_model is None:
+                commit_cmds.append(topic_domain.TopicChange({
+                    'cmd': topic_domain.CMD_REMOVE_UNCATEGORIZED_SKILL_ID,
+                    'uncategorized_skill_id': skill_id
+                }))
+                all_skill_ids_to_be_removed.append(skill_id)
+        if commit_cmds:
+            topic_services.update_topic_and_subtopic_pages(
+                feconf.MIGRATION_BOT_USERNAME, item.id, commit_cmds,
+                'Remove deleted skill id.')
+            yield (
+                'Skill IDs deleted for topic %s:' % item.id,
+                all_skill_ids_to_be_removed)
+        yield (RemoveDeletedSkillsFromTopicOneOffJob._PROCESSED_KEY, 1)
+
+    @staticmethod
+    def reduce(key, values):
+        if key == RemoveDeletedSkillsFromTopicOneOffJob._DELETED_KEY:
+            yield (key, ['Encountered %d deleted topics.' % (
+                sum(ast.literal_eval(v) for v in values))])
+        elif key == RemoveDeletedSkillsFromTopicOneOffJob._PROCESSED_KEY:
+            yield (key, ['Processed %d topics.' % (
+                sum(ast.literal_eval(v) for v in values))])
+        else:
+            yield (key, values)
diff --git a/core/jobs_registry.py b/core/jobs_registry.py
--- a/core/jobs_registry.py
+++ b/core/jobs_registry.py
@@ -73,6 +73,7 @@
     stats_jobs_one_off.StatisticsAuditV2,
     stats_jobs_one_off.StatisticsAudit,
     story_jobs_one_off.StoryMigrationOneOffJob,
+    topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob,
     topic_jobs_one_off.TopicMigrationOneOffJob,
     user_id_migration.GaeIdNotInModelsVerificationJob,
     user_id_migration.ModelsUserIdsHaveUserSettingsExplorationsVerificationJob,


Test patch:
diff --git a/core/controllers/skill_editor_test.py b/core/controllers/skill_editor_test.py
--- a/core/controllers/skill_editor_test.py
+++ b/core/controllers/skill_editor_test.py
@@ -216,17 +216,39 @@ def test_editable_skill_handler_put_fails(self):
     def test_editable_skill_handler_delete_succeeds(self):
         self.login(self.ADMIN_EMAIL)
         # Check that admins can delete a skill.
-        self.delete_json(self.url)
+        skill_has_topics_swap = self.swap(
+            topic_services,
+            'get_all_skill_ids_assigned_to_some_topic',
+            lambda: [])
+        with skill_has_topics_swap:
+            self.delete_json(self.url)
         self.logout()
 
-    def test_editable_skill_handler_delete_fails(self):
+    def test_editable_skill_handler_delete_when_associated_questions_exist(
+            self):
         self.login(self.ADMIN_EMAIL)
-        # Check DELETE returns 500 when the skill still has associated
+        # Check DELETE returns 400 when the skill still has associated
         # questions.
         skill_has_questions_swap = self.swap(
             skill_services, 'skill_has_associated_questions', lambda x: True)
-        with skill_has_questions_swap:
-            self.delete_json(self.url, expected_status_int=500)
+        skill_has_topics_swap = self.swap(
+            topic_services,
+            'get_all_skill_ids_assigned_to_some_topic',
+            lambda: [])
+        with skill_has_questions_swap, skill_has_topics_swap:
+            self.delete_json(self.url, expected_status_int=400)
+        self.logout()
+
+    def test_editable_skill_handler_delete_when_associated_topics_exist(self):
+        self.login(self.ADMIN_EMAIL)
+        # Check DELETE returns 400 when the skill still has associated
+        # topics.
+        skill_has_topics_swap = self.swap(
+            topic_services,
+            'get_all_skill_ids_assigned_to_some_topic',
+            lambda: [self.skill_id])
+        with skill_has_topics_swap:
+            self.delete_json(self.url, expected_status_int=400)
         self.logout()
 
 
diff --git a/core/domain/topic_jobs_one_off_test.py b/core/domain/topic_jobs_one_off_test.py
--- a/core/domain/topic_jobs_one_off_test.py
+++ b/core/domain/topic_jobs_one_off_test.py
@@ -22,6 +22,9 @@
 import ast
 import logging
 
+from constants import constants
+from core.domain import skill_domain
+from core.domain import skill_services
 from core.domain import topic_domain
 from core.domain import topic_fetchers
 from core.domain import topic_jobs_one_off
@@ -202,3 +205,137 @@ def _mock_logging_function(msg):
                      [u'Topic topic_id failed validation: '
                       'Invalid language code: invalid_language_code']]]
         self.assertEqual(expected, [ast.literal_eval(x) for x in output])
+
+
+class RemoveDeletedSkillsFromTopicOneOffJobTests(
+        test_utils.GenericTestBase):
+
+    ALBERT_EMAIL = 'albert@example.com'
+    ALBERT_NAME = 'albert'
+
+    TOPIC_ID = 'topic_id'
+
+    def setUp(self):
+        super(RemoveDeletedSkillsFromTopicOneOffJobTests, self).setUp()
+        # Setup user who will own the test topics.
+        self.signup(self.ALBERT_EMAIL, self.ALBERT_NAME)
+        self.albert_id = self.get_user_id_from_email(self.ALBERT_EMAIL)
+        self.process_and_flush_pending_tasks()
+        self.rubrics = [
+            skill_domain.Rubric(
+                constants.SKILL_DIFFICULTIES[0], ['Explanation 1']),
+            skill_domain.Rubric(
+                constants.SKILL_DIFFICULTIES[1], ['Explanation 2']),
+            skill_domain.Rubric(
+                constants.SKILL_DIFFICULTIES[2], ['Explanation 3'])]
+
+    def test_job_removes_deleted_uncategorized_skill_ids(self):
+        """Tests that the RemoveDeletedSkillsFromTopicOneOffJob job removes
+        deleted uncategorized skills ids from the topic.
+        """
+        valid_skill_1 = skill_domain.Skill.create_default_skill(
+            'valid_skill_1', description='A description', rubrics=self.rubrics)
+        valid_skill_2 = skill_domain.Skill.create_default_skill(
+            'valid_skill_2', description='A description', rubrics=self.rubrics)
+        valid_skill_3 = skill_domain.Skill.create_default_skill(
+            'valid_skill_3', description='A description', rubrics=self.rubrics)
+        skill_services.save_new_skill(self.albert_id, valid_skill_1)
+        skill_services.save_new_skill(self.albert_id, valid_skill_2)
+        skill_services.save_new_skill(self.albert_id, valid_skill_3)
+        # Create a new topic that should not be affected by the
+        # job.
+        topic = topic_domain.Topic.create_default_topic(
+            self.TOPIC_ID, name='A name', abbreviated_name='abbrev')
+        topic.add_subtopic(1, title='A subtitle')
+        topic.add_uncategorized_skill_id('valid_skill_1')
+        topic.add_uncategorized_skill_id('valid_skill_2')
+        topic.add_uncategorized_skill_id('valid_skill_3')
+        topic.add_uncategorized_skill_id('deleted_skill_1')
+        topic.add_uncategorized_skill_id('deleted_skill_2')
+        topic.add_uncategorized_skill_id('deleted_skill_3')
+        topic.move_skill_id_to_subtopic(None, 1, 'valid_skill_3')
+        topic.move_skill_id_to_subtopic(None, 1, 'deleted_skill_3')
+        topic_services.save_new_topic(self.albert_id, topic)
+        # Pre-assert that all skills are added correctly.
+        self.assertEqual(
+            set(topic.uncategorized_skill_ids),
+            set([
+                'valid_skill_1',
+                'valid_skill_2',
+                'deleted_skill_1',
+                'deleted_skill_2'
+            ]))
+        self.assertEqual(
+            set(topic.subtopics[0].skill_ids),
+            set(['valid_skill_3', 'deleted_skill_3']))
+
+        # Start RemoveDeletedSkillsFromTopicOneOffJob.
+        job_id = (
+            topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob
+            .create_new())
+        topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob.enqueue(
+            job_id)
+        self.process_and_flush_pending_tasks()
+
+        # Assert that only valid skills remain after
+        # RemoveDeletedSkillsFromTopicOneOffJob.
+        updated_topic = topic_fetchers.get_topic_by_id(self.TOPIC_ID)
+        self.assertEqual(
+            updated_topic.uncategorized_skill_ids,
+            ['valid_skill_1', 'valid_skill_2'])
+        self.assertEqual(
+            updated_topic.subtopics[0].skill_ids, ['valid_skill_3'])
+        output = (
+            topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob
+            .get_output(job_id))
+        expected = [
+            [
+                u'Skill IDs deleted for topic topic_id:',
+                [u'[u\'deleted_skill_1\', u\'deleted_skill_2\','
+                 ' u\'deleted_skill_3\']']
+            ],
+            [u'topic_processed', [u'Processed 1 topics.']]
+        ]
+
+        self.assertEqual(expected, [ast.literal_eval(x) for x in output])
+
+    def test_job_skips_deleted_topic(self):
+        """Tests that RemoveDeletedSkillsFromTopicOneOffJob job skips
+        deleted topic and does not attempt to remove uncategorized skills for
+        skills that are deleted.
+        """
+        topic = topic_domain.Topic.create_default_topic(
+            self.TOPIC_ID, name='A name', abbreviated_name='abbrev')
+        topic.add_uncategorized_skill_id('skill_1')
+        topic.add_uncategorized_skill_id('skill_2')
+        topic_services.save_new_topic(self.albert_id, topic)
+
+        # Delete the topic before migration occurs.
+        topic_services.delete_topic(
+            self.albert_id, self.TOPIC_ID)
+
+        # Ensure the topic is deleted.
+        with self.assertRaisesRegexp(Exception, 'Entity .* not found'):
+            topic_fetchers.get_topic_by_id(self.TOPIC_ID)
+
+        # Start migration job on sample topic.
+        job_id = (
+            topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob
+            .create_new())
+        topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob.enqueue(
+            job_id)
+
+        # This running without errors indicates the deleted topic is
+        # being ignored.
+        self.process_and_flush_pending_tasks()
+
+        # Ensure the topic is still deleted.
+        with self.assertRaisesRegexp(Exception, 'Entity .* not found'):
+            topic_fetchers.get_topic_by_id(self.TOPIC_ID)
+
+        output = (
+            topic_jobs_one_off.RemoveDeletedSkillsFromTopicOneOffJob
+            .get_output(job_id))
+        expected = [[u'topic_deleted',
+                     [u'Encountered 1 deleted topics.']]]
+        self.assertEqual(expected, [ast.literal_eval(x) for x in output])