Repository: oppia/oppia

Issue:
Cannot assign prereq skills from other topics + invalid prereq skill error message is not comprehensible.
**Describe the bug**
In the story chapter editor, introducing a prereq skill from another topic leads to a validation error that is incomprehensible (e.g. "The prerequisite skill with id XXX was not completed before node with id node_3 was unlocked").

**To Reproduce**
Steps to reproduce the behavior:
 1. Create a skill, P, and assign it to a new topic, T1.
 2. Create a different topic, T2, and go to the topic editor for T2.
 3. Create a new story in topic T2, and open the story editor.
 4. Create a new chapter in that story, and give it a prerequisite skill of P.

**Observed behavior**
It will look as though the skill has been assigned without any issues. However, if you go back to the main page of the story, it will not be possible to save the changes, and you will get an error message that looks something like "The prerequisite skill with id XXX was not completed before node with id node_3 was unlocked)." In addition, the prerequisite skill will not actually show up in the chapter editor (see screenshot below):

![Screenshot from 2020-10-26 18-07-05](https://user-images.githubusercontent.com/10575562/97245288-f150f200-17b7-11eb-83ba-c042dc096ccc.png)

**Expected behavior**
It should be possible to add prerequisite skills from other topics without any issues.

Also, if a prerequisite skill (within the given topic) has not been acquired in a previous chapter, the error should indeed show, but it should be comprehensible (e.g. "The skill '...' from topic '...' was specified as a prerequisite for Chapter 3, but was not taught in any chapter before it").
Cannot assign prereq skills from other topics + invalid prereq skill error message is not comprehensible.
**Describe the bug**
In the story chapter editor, introducing a prereq skill from another topic leads to a validation error that is incomprehensible (e.g. "The prerequisite skill with id XXX was not completed before node with id node_3 was unlocked").

**To Reproduce**
Steps to reproduce the behavior:
 1. Create a skill, P, and assign it to a new topic, T1.
 2. Create a different topic, T2, and go to the topic editor for T2.
 3. Create a new story in topic T2, and open the story editor.
 4. Create a new chapter in that story, and give it a prerequisite skill of P.

**Observed behavior**
It will look as though the skill has been assigned without any issues. However, if you go back to the main page of the story, it will not be possible to save the changes, and you will get an error message that looks something like "The prerequisite skill with id XXX was not completed before node with id node_3 was unlocked)." In addition, the prerequisite skill will not actually show up in the chapter editor (see screenshot below):

![Screenshot from 2020-10-26 18-07-05](https://user-images.githubusercontent.com/10575562/97245288-f150f200-17b7-11eb-83ba-c042dc096ccc.png)

**Expected behavior**
It should be possible to add prerequisite skills from other topics without any issues.

Also, if a prerequisite skill (within the given topic) has not been acquired in a previous chapter, the error should indeed show, but it should be comprehensible (e.g. "The skill '...' from topic '...' was specified as a prerequisite for Chapter 3, but was not taught in any chapter before it").


Discussion:
@kevintab95 Hi, are you working on this?
Hi @seanlip, I was unable to reproduce this error on the test server.
Here's a [video](https://drive.google.com/file/d/1cuuLW5_0X7lcqX1-r0wQEn1w5KuY4LjT/view?usp=sharing) showing what I did. I tried to add a skill called "Skill for test topic" which was assigned to a topic called "test topic" as a prerequisite skill for a story in a different topic and it seemed to work fine.

Please let me know if I am missing something. Thanks!



This might need to be done in the non-first chapter. Here is a link to a video showing what it looks like on the main site: https://drive.google.com/file/d/1FYpI_YT21khXOslZNeGNygUVCWB1lOjd/view?usp=sharing

Ah, I see. Confirming that I am able to reproduce this when I add an assigned skill to a non-first chapter in a story belonging to a different topic.
Hi @seanlip, does this message seem reasonable:

"The skills with ids xxx, yyy, zzz were specified as prerequisites for Chapter 3, but were not taught in any chapter before it"

Do we need to map the skill ids to the names or is showing ids sufficient?
Yup, just drop "a". Note that this should apply only to skills which the
topic is responsible for teaching, and not to external prerequisites.

@kevintab95 Any update on this?
Yep, I will try to raise a PR by 25 Nov EOD.
@kevintab95 Hi, are you working on this?
Hi @seanlip, I was unable to reproduce this error on the test server.
Here's a [video](https://drive.google.com/file/d/1cuuLW5_0X7lcqX1-r0wQEn1w5KuY4LjT/view?usp=sharing) showing what I did. I tried to add a skill called "Skill for test topic" which was assigned to a topic called "test topic" as a prerequisite skill for a story in a different topic and it seemed to work fine.

Please let me know if I am missing something. Thanks!



This might need to be done in the non-first chapter. Here is a link to a video showing what it looks like on the main site: https://drive.google.com/file/d/1FYpI_YT21khXOslZNeGNygUVCWB1lOjd/view?usp=sharing

Ah, I see. Confirming that I am able to reproduce this when I add an assigned skill to a non-first chapter in a story belonging to a different topic.
Hi @seanlip, does this message seem reasonable:

"The skills with ids xxx, yyy, zzz were specified as prerequisites for Chapter 3, but were not taught in any chapter before it"

Do we need to map the skill ids to the names or is showing ids sufficient?
Yup, just drop "a". Note that this should apply only to skills which the
topic is responsible for teaching, and not to external prerequisites.

@kevintab95 Any update on this?
Yep, I will try to raise a PR by 25 Nov EOD.

Resolving patch:
diff --git a/core/domain/story_domain.py b/core/domain/story_domain.py
--- a/core/domain/story_domain.py
+++ b/core/domain/story_domain.py
@@ -527,57 +527,6 @@ def validate(self):
                 raise utils.ValidationError(
                     'Expected all chapter titles to be distinct.')
 
-            # nodes_queue stores the pending nodes to visit in the story that
-            # are unlocked, in a 'queue' form with a First In First Out
-            # structure.
-            nodes_queue = []
-            is_node_visited = [False] * len(self.nodes)
-            starting_node_index = self.get_node_index(self.initial_node_id)
-            nodes_queue.append(self.nodes[starting_node_index].id)
-
-            # The user is assumed to have all the prerequisite skills of the
-            # starting node before starting the story. Also, this list models
-            # the skill IDs acquired by a learner as they progress through the
-            # story.
-            simulated_skill_ids = copy.deepcopy(
-                self.nodes[starting_node_index].prerequisite_skill_ids)
-
-            # The following loop employs a Breadth First Search from the given
-            # starting node and makes sure that the user has acquired all the
-            # prerequisite skills required by the destination nodes 'unlocked'
-            # by visiting a particular node by the time that node is finished.
-            while len(nodes_queue) > 0:
-                current_node_id = nodes_queue.pop()
-                current_node_index = self.get_node_index(current_node_id)
-                is_node_visited[current_node_index] = True
-                current_node = self.nodes[current_node_index]
-
-                for skill_id in current_node.acquired_skill_ids:
-                    simulated_skill_ids.append(skill_id)
-
-                for node_id in current_node.destination_node_ids:
-                    node_index = self.get_node_index(node_id)
-                    # The following condition checks whether the destination
-                    # node for a particular node, has already been visited, in
-                    # which case the story would have loops, which are not
-                    # allowed.
-                    if is_node_visited[node_index]:
-                        raise utils.ValidationError(
-                            'Loops are not allowed in stories.')
-                    destination_node = self.nodes[node_index]
-                    if not (
-                            set(
-                                destination_node.prerequisite_skill_ids
-                            ).issubset(simulated_skill_ids)):
-                        raise utils.ValidationError(
-                            'The prerequisite skills ' +
-                            ' '.join(
-                                set(destination_node.prerequisite_skill_ids) -
-                                set(simulated_skill_ids)) +
-                            ' were not completed before the node with id %s'
-                            ' was unlocked.' % node_id)
-                    nodes_queue.append(node_id)
-
     def get_node_index(self, node_id):
         """Returns the index of the story node with the given node
         id, or None if the node id is not in the story contents dict.
diff --git a/core/domain/story_jobs_one_off.py b/core/domain/story_jobs_one_off.py
--- a/core/domain/story_jobs_one_off.py
+++ b/core/domain/story_jobs_one_off.py
@@ -60,6 +60,8 @@ def map(item):
         story = story_fetchers.get_story_by_id(item.id)
         try:
             story.validate()
+            story_services.validate_prerequisite_skills_in_story_contents(
+                story.corresponding_topic_id, story.story_contents)
         except Exception as e:
             logging.error(
                 'Story %s failed validation: %s' % (item.id, e))
diff --git a/core/domain/story_services.py b/core/domain/story_services.py
--- a/core/domain/story_services.py
+++ b/core/domain/story_services.py
@@ -23,6 +23,7 @@
 from __future__ import absolute_import  # pylint: disable=import-only-modules
 from __future__ import unicode_literals  # pylint: disable=import-only-modules
 
+import copy
 import logging
 
 from constants import constants
@@ -234,6 +235,82 @@ def does_story_exist_with_url_fragment(url_fragment):
     return story is not None
 
 
+def validate_prerequisite_skills_in_story_contents(
+        corresponding_topic_id, story_contents):
+    """Validates the prerequisites skills in the story contents.
+
+    Args:
+        corresponding_topic_id: str. The corresponding topic id of the story.
+        story_contents: StoryContents. The story contents.
+
+    Raises:
+        ValidationError. Expected prerequisite skills to have been acquired in
+            previous nodes.
+        ValidationError. Expected story to not contain loops.
+    """
+    if len(story_contents.nodes) == 0:
+        return
+    # nodes_queue stores the pending nodes to visit in the story that
+    # are unlocked, in a 'queue' form with a First In First Out
+    # structure.
+    nodes_queue = []
+    is_node_visited = [False] * len(story_contents.nodes)
+    starting_node_index = story_contents.get_node_index(
+        story_contents.initial_node_id)
+    nodes_queue.append(story_contents.nodes[starting_node_index].id)
+
+    # The user is assumed to have all the prerequisite skills of the
+    # starting node before starting the story. Also, this list models
+    # the skill IDs acquired by a learner as they progress through the
+    # story.
+    simulated_skill_ids = copy.deepcopy(
+        story_contents.nodes[starting_node_index].prerequisite_skill_ids)
+
+    # The following loop employs a Breadth First Search from the given
+    # starting node and makes sure that the user has acquired all the
+    # prerequisite skills required by the destination nodes 'unlocked'
+    # by visiting a particular node by the time that node is finished.
+    while len(nodes_queue) > 0:
+        current_node_id = nodes_queue.pop()
+        current_node_index = story_contents.get_node_index(current_node_id)
+        is_node_visited[current_node_index] = True
+        current_node = story_contents.nodes[current_node_index]
+
+        for skill_id in current_node.acquired_skill_ids:
+            simulated_skill_ids.append(skill_id)
+
+        for node_id in current_node.destination_node_ids:
+            node_index = story_contents.get_node_index(node_id)
+            # The following condition checks whether the destination
+            # node for a particular node, has already been visited, in
+            # which case the story would have loops, which are not
+            # allowed.
+            if is_node_visited[node_index]:
+                raise utils.ValidationError(
+                    'Loops are not allowed in stories.')
+            destination_node = story_contents.nodes[node_index]
+            skill_ids_present_in_topic = (
+                topic_fetchers.get_topic_by_id(
+                    corresponding_topic_id).get_all_skill_ids())
+            # Include only skill ids relevant to the topic for validation.
+            topic_relevant_skill_ids = list(
+                set(skill_ids_present_in_topic).intersection(
+                    set(destination_node.prerequisite_skill_ids)))
+            if not (
+                    set(
+                        topic_relevant_skill_ids
+                    ).issubset(simulated_skill_ids)):
+                raise utils.ValidationError(
+                    'The skills with ids ' +
+                    ' '.join(
+                        set(topic_relevant_skill_ids) -
+                        set(simulated_skill_ids)) +
+                    ' were specified as prerequisites for Chapter %s,'
+                    ' but were not taught in any chapter before it.'
+                    % destination_node.title)
+            nodes_queue.append(node_id)
+
+
 def validate_explorations_for_story(exp_ids, raise_error):
     """Validates the explorations in the given story and checks whether they
     are compatible with the mobile app and ready for publishing.
@@ -398,6 +475,8 @@ def _save_story(
             'save story %s: %s' % (story.id, change_list))
 
     story.validate()
+    validate_prerequisite_skills_in_story_contents(
+        story.corresponding_topic_id, story.story_contents)
 
     if story_is_published:
         exp_ids = []


Test patch:
diff --git a/core/domain/story_domain_test.py b/core/domain/story_domain_test.py
--- a/core/domain/story_domain_test.py
+++ b/core/domain/story_domain_test.py
@@ -795,110 +795,7 @@ def test_get_node_with_corresponding_exp_id_with_invalid_exp_id(self):
 
     def test_all_nodes_visited(self):
         self.story.story_contents.next_node_id = 'node_4'
-        # Case 1: Prerequisite skills not acquired.
-        node_1 = {
-            'id': 'node_1',
-            'thumbnail_filename': 'image.svg',
-            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
-                'chapter'][0],
-            'title': 'Title 1',
-            'description': 'Description 1',
-            'destination_node_ids': ['node_2', 'node_3'],
-            'acquired_skill_ids': ['skill_2'],
-            'prerequisite_skill_ids': ['skill_1'],
-            'outline': '',
-            'outline_is_finalized': False,
-            'exploration_id': None
-        }
-        node_2 = {
-            'id': 'node_2',
-            'thumbnail_filename': 'image.svg',
-            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
-                'chapter'][0],
-            'title': 'Title 2',
-            'description': 'Description 2',
-            'destination_node_ids': [],
-            'acquired_skill_ids': ['skill_3'],
-            'prerequisite_skill_ids': ['skill_2'],
-            'outline': '',
-            'outline_is_finalized': False,
-            'exploration_id': None
-        }
-        node_3 = {
-            'id': 'node_3',
-            'thumbnail_filename': 'image.svg',
-            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
-                'chapter'][0],
-            'title': 'Title 3',
-            'description': 'Description 3',
-            'destination_node_ids': [],
-            'acquired_skill_ids': ['skill_4'],
-            'prerequisite_skill_ids': ['skill_3'],
-            'outline': '',
-            'outline_is_finalized': False,
-            'exploration_id': None
-        }
-        self.story.story_contents.initial_node_id = 'node_1'
-        self.story.story_contents.nodes = [
-            story_domain.StoryNode.from_dict(node_1),
-            story_domain.StoryNode.from_dict(node_2),
-            story_domain.StoryNode.from_dict(node_3)
-        ]
-        self._assert_validation_error(
-            'The prerequisite skills skill_3 were not completed before '
-            'the node with id node_3 was unlocked.')
-
-        # Case 2: Story with loops.
-        node_1 = {
-            'id': 'node_1',
-            'thumbnail_filename': 'image.svg',
-            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
-                'chapter'][0],
-            'title': 'Title 1',
-            'description': 'Description 1',
-            'destination_node_ids': ['node_2'],
-            'acquired_skill_ids': ['skill_2'],
-            'prerequisite_skill_ids': ['skill_1'],
-            'outline': '',
-            'outline_is_finalized': False,
-            'exploration_id': None
-        }
-        node_2 = {
-            'id': 'node_2',
-            'thumbnail_filename': 'image.svg',
-            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
-                'chapter'][0],
-            'title': 'Title 2',
-            'description': 'Description 2',
-            'destination_node_ids': ['node_3'],
-            'acquired_skill_ids': ['skill_3'],
-            'prerequisite_skill_ids': ['skill_2'],
-            'outline': '',
-            'outline_is_finalized': False,
-            'exploration_id': None
-        }
-        node_3 = {
-            'id': 'node_3',
-            'thumbnail_filename': 'image.svg',
-            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
-                'chapter'][0],
-            'title': 'Title 3',
-            'description': 'Description 3',
-            'destination_node_ids': ['node_2'],
-            'acquired_skill_ids': ['skill_4'],
-            'prerequisite_skill_ids': ['skill_3'],
-            'outline': '',
-            'outline_is_finalized': False,
-            'exploration_id': None
-        }
-        self.story.story_contents.nodes = [
-            story_domain.StoryNode.from_dict(node_1),
-            story_domain.StoryNode.from_dict(node_2),
-            story_domain.StoryNode.from_dict(node_3)
-        ]
-        self._assert_validation_error('Loops are not allowed in stories.')
-
-        # Case 3: Disconnected graph.
+        # Case 1: Disconnected graph.
         node_1 = {
             'id': 'node_1',
             'thumbnail_filename': 'image.svg',
@@ -947,7 +844,7 @@ def test_all_nodes_visited(self):
             story_domain.StoryNode.from_dict(node_3)
         ]
 
-        # Case 4: Graph with duplicate nodes.
+        # Case 2: Graph with duplicate nodes.
         node_1 = {
             'id': 'node_1',
             'thumbnail_filename': 'image.svg',
@@ -997,7 +894,7 @@ def test_all_nodes_visited(self):
         ]
         self._assert_validation_error('Expected all node ids to be distinct')
 
-        # Case 5: Graph with duplicate titles.
+        # Case 3: Graph with duplicate titles.
         node_1 = {
             'id': 'node_1',
             'title': 'Title 1',
@@ -1049,7 +946,7 @@ def test_all_nodes_visited(self):
             'Expected all chapter titles to be distinct.')
 
         self.story.story_contents.next_node_id = 'node_5'
-        # Case 6: A valid graph.
+        # Case 4: A valid graph.
         node_1 = {
             'id': 'node_1',
             'thumbnail_filename': 'image.svg',
diff --git a/core/domain/story_services_test.py b/core/domain/story_services_test.py
--- a/core/domain/story_services_test.py
+++ b/core/domain/story_services_test.py
@@ -32,6 +32,7 @@
 from core.tests import test_utils
 
 import feconf
+import utils
 
 (story_models, user_models) = models.Registry.import_models(
     [models.NAMES.story, models.NAMES.user])
@@ -63,7 +64,8 @@ def setUp(self):
             abbreviated_name='topic-one', url_fragment='topic-one',
             description='A new topic',
             canonical_story_ids=[], additional_story_ids=[],
-            uncategorized_skill_ids=[], subtopics=[], next_subtopic_id=0)
+            uncategorized_skill_ids=['skill_4'], subtopics=[],
+            next_subtopic_id=0)
         self.save_new_story(self.STORY_ID, self.USER_ID, self.TOPIC_ID)
         topic_services.add_canonical_story(
             self.USER_ID, self.TOPIC_ID, self.STORY_ID)
@@ -295,6 +297,120 @@ def test_update_story_node_properties(self):
         self.assertEqual(
             story.story_contents.nodes[0].outline_is_finalized, False)
 
+    def test_prerequisite_skills_validation(self):
+        self.story.story_contents.next_node_id = 'node_4'
+        node_1 = {
+            'id': 'node_1',
+            'thumbnail_filename': 'image.svg',
+            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
+                'chapter'][0],
+            'title': 'Title 1',
+            'description': 'Description 1',
+            'destination_node_ids': ['node_2', 'node_3'],
+            'acquired_skill_ids': ['skill_2'],
+            'prerequisite_skill_ids': ['skill_1'],
+            'outline': '',
+            'outline_is_finalized': False,
+            'exploration_id': None
+        }
+        node_2 = {
+            'id': 'node_2',
+            'thumbnail_filename': 'image.svg',
+            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
+                'chapter'][0],
+            'title': 'Title 2',
+            'description': 'Description 2',
+            'destination_node_ids': [],
+            'acquired_skill_ids': ['skill_3'],
+            'prerequisite_skill_ids': ['skill_2'],
+            'outline': '',
+            'outline_is_finalized': False,
+            'exploration_id': None
+        }
+        node_3 = {
+            'id': 'node_3',
+            'thumbnail_filename': 'image.svg',
+            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
+                'chapter'][0],
+            'title': 'Title 3',
+            'description': 'Description 3',
+            'destination_node_ids': [],
+            'acquired_skill_ids': [],
+            'prerequisite_skill_ids': ['skill_4'],
+            'outline': '',
+            'outline_is_finalized': False,
+            'exploration_id': None
+        }
+        self.story.story_contents.initial_node_id = 'node_1'
+        self.story.story_contents.nodes = [
+            story_domain.StoryNode.from_dict(node_1),
+            story_domain.StoryNode.from_dict(node_2),
+            story_domain.StoryNode.from_dict(node_3)
+        ]
+
+        expected_error_string = (
+            'The skills with ids skill_4 were specified as prerequisites for '
+            'Chapter Title 3, but were not taught in any chapter before it')
+        with self.assertRaisesRegexp(
+            utils.ValidationError, expected_error_string):
+            story_services.validate_prerequisite_skills_in_story_contents(
+                self.story.corresponding_topic_id, self.story.story_contents)
+
+    def test_story_with_loop(self):
+        self.story.story_contents.next_node_id = 'node_4'
+        node_1 = {
+            'id': 'node_1',
+            'thumbnail_filename': 'image.svg',
+            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
+                'chapter'][0],
+            'title': 'Title 1',
+            'description': 'Description 1',
+            'destination_node_ids': ['node_2'],
+            'acquired_skill_ids': ['skill_2'],
+            'prerequisite_skill_ids': ['skill_1'],
+            'outline': '',
+            'outline_is_finalized': False,
+            'exploration_id': None
+        }
+        node_2 = {
+            'id': 'node_2',
+            'thumbnail_filename': 'image.svg',
+            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
+                'chapter'][0],
+            'title': 'Title 2',
+            'description': 'Description 2',
+            'destination_node_ids': ['node_3'],
+            'acquired_skill_ids': ['skill_3'],
+            'prerequisite_skill_ids': ['skill_2'],
+            'outline': '',
+            'outline_is_finalized': False,
+            'exploration_id': None
+        }
+        node_3 = {
+            'id': 'node_3',
+            'thumbnail_filename': 'image.svg',
+            'thumbnail_bg_color': constants.ALLOWED_THUMBNAIL_BG_COLORS[
+                'chapter'][0],
+            'title': 'Title 3',
+            'description': 'Description 3',
+            'destination_node_ids': ['node_2'],
+            'acquired_skill_ids': ['skill_4'],
+            'prerequisite_skill_ids': ['skill_3'],
+            'outline': '',
+            'outline_is_finalized': False,
+            'exploration_id': None
+        }
+        self.story.story_contents.nodes = [
+            story_domain.StoryNode.from_dict(node_1),
+            story_domain.StoryNode.from_dict(node_2),
+            story_domain.StoryNode.from_dict(node_3)
+        ]
+        expected_error_string = 'Loops are not allowed in stories.'
+        with self.assertRaisesRegexp(
+            utils.ValidationError, expected_error_string):
+            story_services.validate_prerequisite_skills_in_story_contents(
+                self.story.corresponding_topic_id, self.story.story_contents)
+
     def test_does_story_exist_with_url_fragment(self):
         story_id_1 = story_services.get_new_story_id()
         story_id_2 = story_services.get_new_story_id()