Repository: oppia/oppia

Issue:
A merged skills break the Topic editor
When two skills that are assigned to two topics are merged, one of the topics will then be broken and will contain `null` skill.
![image](https://user-images.githubusercontent.com/10142938/104608638-6844ab00-5682-11eb-8aa4-4d5610636579.png)
![image](https://user-images.githubusercontent.com/10142938/104608850-ae9a0a00-5682-11eb-9f39-e05ae30da62f.png)

A merged skills break the Topic editor
When two skills that are assigned to two topics are merged, one of the topics will then be broken and will contain `null` skill.
![image](https://user-images.githubusercontent.com/10142938/104608638-6844ab00-5682-11eb-8aa4-4d5610636579.png)
![image](https://user-images.githubusercontent.com/10142938/104608850-ae9a0a00-5682-11eb-9f39-e05ae30da62f.png)

A merged skills break the Topic editor
When two skills that are assigned to two topics are merged, one of the topics will then be broken and will contain `null` skill.
![image](https://user-images.githubusercontent.com/10142938/104608638-6844ab00-5682-11eb-8aa4-4d5610636579.png)
![image](https://user-images.githubusercontent.com/10142938/104608850-ae9a0a00-5682-11eb-9f39-e05ae30da62f.png)



Discussion:

Resolving patch:
diff --git a/core/controllers/topics_and_skills_dashboard.py b/core/controllers/topics_and_skills_dashboard.py
--- a/core/controllers/topics_and_skills_dashboard.py
+++ b/core/controllers/topics_and_skills_dashboard.py
@@ -397,6 +397,8 @@ def post(self):
                 Exception('The old skill with the given id doesn\'t exist.'))
         question_services.replace_skill_id_for_all_questions(
             old_skill_id, old_skill.description, new_skill_id)
+        skill_services.replace_skill_id_in_all_topics(
+            self.user_id, old_skill_id, new_skill_id)
         changelist = [
             skill_domain.SkillChange({
                 'cmd': skill_domain.CMD_UPDATE_SKILL_PROPERTY,
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
@@ -441,6 +441,56 @@ def get_all_topic_assignments_for_skill(skill_id):
     return topic_assignments
 
 
+def replace_skill_id_in_all_topics(user_id, old_skill_id, new_skill_id):
+    """Replaces the old skill id with the new one in all the associated topics.
+
+    Args:
+        user_id: str. The unique user ID of the user.
+        old_skill_id: str. The old skill id.
+        new_skill_id: str. The new skill id.
+    """
+    all_topics = topic_fetchers.get_all_topics()
+    for topic in all_topics:
+        change_list = []
+        if old_skill_id in topic.get_all_skill_ids():
+            if new_skill_id in topic.get_all_skill_ids():
+                raise Exception(
+                    'Found topic \'%s\' contains the two skills to be merged. '
+                    'Please unassign one of these skills from topic '
+                    'and retry this operation.' % topic.name)
+            if old_skill_id in topic.uncategorized_skill_ids:
+                change_list.extend([topic_domain.TopicChange({
+                    'cmd': 'remove_uncategorized_skill_id',
+                    'uncategorized_skill_id': old_skill_id
+                }), topic_domain.TopicChange({
+                    'cmd': 'add_uncategorized_skill_id',
+                    'new_uncategorized_skill_id': new_skill_id
+                })])
+            for subtopic in topic.subtopics:
+                if old_skill_id in subtopic.skill_ids:
+                    change_list.extend([topic_domain.TopicChange({
+                        'cmd': topic_domain.CMD_REMOVE_SKILL_ID_FROM_SUBTOPIC,
+                        'subtopic_id': subtopic.id,
+                        'skill_id': old_skill_id
+                    }), topic_domain.TopicChange({
+                        'cmd': 'remove_uncategorized_skill_id',
+                        'uncategorized_skill_id': old_skill_id
+                    }), topic_domain.TopicChange({
+                        'cmd': 'add_uncategorized_skill_id',
+                        'new_uncategorized_skill_id': new_skill_id
+                    }), topic_domain.TopicChange({
+                        'cmd': topic_domain.CMD_MOVE_SKILL_ID_TO_SUBTOPIC,
+                        'old_subtopic_id': None,
+                        'new_subtopic_id': subtopic.id,
+                        'skill_id': new_skill_id
+                    })])
+                    break
+            topic_services.update_topic_and_subtopic_pages(
+                user_id, topic.id, change_list,
+                'Replace skill id %s with skill id %s in the topic' % (
+                    old_skill_id, new_skill_id))
+
+
 def remove_skill_from_all_topics(user_id, skill_id):
     """Deletes the skill with the given id from all the associated topics.
 


Test patch:
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
@@ -636,6 +636,61 @@ def test_remove_skill_from_all_topics(self):
             skill_services.get_all_topic_assignments_for_skill(self.SKILL_ID))
         self.assertEqual(len(topic_assignments_dict), 0)
 
+    def test_successfully_replace_skill_id_in_all_topics(self):
+        topic_id = topic_services.get_new_topic_id()
+        topic_id_1 = topic_services.get_new_topic_id()
+        self.save_new_topic(
+            topic_id, self.USER_ID, name='Topic1',
+            abbreviated_name='topic-five', url_fragment='topic-five',
+            description='Description',
+            canonical_story_ids=[],
+            additional_story_ids=[],
+            uncategorized_skill_ids=[self.SKILL_ID],
+            subtopics=[], next_subtopic_id=1)
+
+        subtopic = topic_domain.Subtopic.from_dict({
+            'id': 1,
+            'title': 'subtopic1',
+            'skill_ids': [self.SKILL_ID],
+            'thumbnail_filename': None,
+            'thumbnail_bg_color': None,
+            'url_fragment': 'subtopic-one'
+        })
+        self.save_new_topic(
+            topic_id_1, self.USER_ID, name='Topic2',
+            abbreviated_name='topic-six', url_fragment='topic-six',
+            description='Description2', canonical_story_ids=[],
+            additional_story_ids=[],
+            uncategorized_skill_ids=[],
+            subtopics=[subtopic], next_subtopic_id=2)
+
+        topic_assignments_dict = (
+            skill_services.get_all_topic_assignments_for_skill('new_skill_id'))
+        self.assertEqual(len(topic_assignments_dict), 0)
+        skill_services.replace_skill_id_in_all_topics(
+            self.USER_ID, self.SKILL_ID, 'new_skill_id')
+        topic_assignments_dict = (
+            skill_services.get_all_topic_assignments_for_skill('new_skill_id'))
+        self.assertEqual(len(topic_assignments_dict), 2)
+
+    def test_failure_replace_skill_id_in_all_topics(self):
+        topic_id = topic_services.get_new_topic_id()
+        self.save_new_topic(
+            topic_id, self.USER_ID, name='Topic1',
+            abbreviated_name='topic-five', url_fragment='topic-five',
+            description='Description',
+            canonical_story_ids=[],
+            additional_story_ids=[],
+            uncategorized_skill_ids=[self.SKILL_ID, 'new_skill_id'],
+            subtopics=[], next_subtopic_id=1)
+        error_message = (
+            'Found topic \'Topic1\' contains the two skills to be merged. '
+            'Please unassign one of these skills from topic '
+            'and retry this operation.')
+        with self.assertRaisesRegexp(Exception, error_message):
+            skill_services.replace_skill_id_in_all_topics(
+                self.USER_ID, self.SKILL_ID, 'new_skill_id')
+
     def test_update_skill(self):
         changelist = [
             skill_domain.SkillChange({