Repository: oppia/oppia

Issue:
There should be an explicit customization option in the sorting interaction to allow multiple sort items in the same position.
I was just updating a Fractions lesson to use the new sorting interaction, and on testing, I found that for a standard sorting question, it was possible for two objects to occupy the same slot. In general, this may be desirable in harder sorting problems, but for a student's first introduction to sorting I think we should have the option to disallow it. It may not be clear to the student that sort items which are "stuck together", similar to 2/5 and 4/5 in the screenshot below, are to be considered equal.

![screenshot from 2018-11-04 01-26-39](https://user-images.githubusercontent.com/10575562/47962391-ff0c7c00-dfd0-11e8-97a5-86e4d2d787de.png)

The aim of this issue is therefore to add a customization option for allowing two or more sort items to occupy the "same slot", and defaulting that customization option to False.
There should be an explicit customization option in the sorting interaction to allow multiple sort items in the same position.
I was just updating a Fractions lesson to use the new sorting interaction, and on testing, I found that for a standard sorting question, it was possible for two objects to occupy the same slot. In general, this may be desirable in harder sorting problems, but for a student's first introduction to sorting I think we should have the option to disallow it. It may not be clear to the student that sort items which are "stuck together", similar to 2/5 and 4/5 in the screenshot below, are to be considered equal.

![screenshot from 2018-11-04 01-26-39](https://user-images.githubusercontent.com/10575562/47962391-ff0c7c00-dfd0-11e8-97a5-86e4d2d787de.png)

The aim of this issue is therefore to add a customization option for allowing two or more sort items to occupy the "same slot", and defaulting that customization option to False.


Discussion:
/cc @vibhor98 -- would you mind looking into this, please?
@vibhor98 are you still working on this?
Hi @jacobdavis11, Not right now but will do after my exams. Thanks! 
@vibhor98, do you have any status updates for this? thanks!
ping @vibhor98! Are you working on this?
Hi @anmolshkl, yes I'm working on it. Due to errors while starting  dev server, I can't test the PR right now and hence, the delay. Thanks!
/cc @vibhor98 -- would you mind looking into this, please?
@vibhor98 are you still working on this?
Hi @jacobdavis11, Not right now but will do after my exams. Thanks! 
@vibhor98, do you have any status updates for this? thanks!
ping @vibhor98! Are you working on this?
Hi @anmolshkl, yes I'm working on it. Due to errors while starting  dev server, I can't test the PR right now and hence, the delay. Thanks!

Resolving patch:
diff --git a/core/domain/exp_domain.py b/core/domain/exp_domain.py
--- a/core/domain/exp_domain.py
+++ b/core/domain/exp_domain.py
@@ -2081,6 +2081,32 @@ def _convert_states_v24_dict_to_v25_dict(cls, exp_id, states_dict):
 
         return states_dict
 
+    @classmethod
+    def _convert_states_v25_dict_to_v26_dict(cls, states_dict):
+        """Converts from version 25 to 26. Version 26 adds a new
+        customization arg to DragAndDropSortInput interaction which allows
+        multiple sort items in the same position.
+
+        Args:
+            states_dict: dict. A dict where each key-value pair represents,
+                respectively, a state name and a dict used to initialize a
+                State domain object.
+
+        Returns:
+            dict. The converted states_dict.
+        """
+        for state_dict in states_dict.values():
+            if state_dict['interaction']['id'] == 'DragAndDropSortInput':
+                customization_args = state_dict[
+                    'interaction']['customization_args']
+                customization_args.update({
+                    'allowMultipleItemsInSamePosition': {
+                        'value': False
+                    }
+                })
+
+        return states_dict
+
     @classmethod
     def update_states_from_model(
             cls, versioned_exploration_states, current_states_schema_version,
@@ -2116,7 +2142,7 @@ def update_states_from_model(
     # incompatible changes are made to the exploration schema in the YAML
     # definitions, this version number must be changed and a migration process
     # put in place.
-    CURRENT_EXP_SCHEMA_VERSION = 30
+    CURRENT_EXP_SCHEMA_VERSION = 31
     LAST_UNTITLED_SCHEMA_VERSION = 9
 
     @classmethod
@@ -2664,6 +2690,21 @@ def _convert_v29_dict_to_v30_dict(cls, exp_id, exploration_dict):
 
         return exploration_dict
 
+    @classmethod
+    def _convert_v30_dict_to_v31_dict(cls, exploration_dict):
+        """Converts a v30 exploration dict into a v31 exploration dict.
+
+        Adds a new customization arg to DragAndDropSortInput interactions
+        which allows multiple sort items in the same position.
+        """
+        exploration_dict['schema_version'] = 31
+
+        exploration_dict['states'] = cls._convert_states_v25_dict_to_v26_dict(
+            exploration_dict['states'])
+        exploration_dict['states_schema_version'] = 26
+
+        return exploration_dict
+
     @classmethod
     def _migrate_to_latest_yaml_version(
             cls, yaml_content, exp_id, title=None, category=None):
@@ -2847,6 +2888,11 @@ def _migrate_to_latest_yaml_version(
                 exp_id, exploration_dict)
             exploration_schema_version = 30
 
+        if exploration_schema_version == 30:
+            exploration_dict = cls._convert_v30_dict_to_v31_dict(
+                exploration_dict)
+            exploration_schema_version = 31
+
         return (exploration_dict, initial_schema_version)
 
     @classmethod
diff --git a/extensions/interactions/DragAndDropSortInput/DragAndDropSortInput.py b/extensions/interactions/DragAndDropSortInput/DragAndDropSortInput.py
--- a/extensions/interactions/DragAndDropSortInput/DragAndDropSortInput.py
+++ b/extensions/interactions/DragAndDropSortInput/DragAndDropSortInput.py
@@ -61,6 +61,13 @@ class DragAndDropSortInput(base.BaseInteraction):
             }
         },
         'default_value': [''],
+    }, {
+        'name': 'allowMultipleItemsInSamePosition',
+        'description': 'Allow multiple sort items in the same position',
+        'schema': {
+            'type': 'bool'
+        },
+        'default_value': False
     }]
 
     _answer_visualization_specs = []
diff --git a/feconf.py b/feconf.py
--- a/feconf.py
+++ b/feconf.py
@@ -150,7 +150,7 @@
 # incompatible changes are made to the states blob schema in the data store,
 # this version number must be changed and the exploration migration job
 # executed.
-CURRENT_STATES_SCHEMA_VERSION = 25
+CURRENT_STATES_SCHEMA_VERSION = 26
 
 # The current version of the all collection blob schemas (such as the nodes
 # structure within the Collection domain object). If any backward-incompatible


Test patch:
diff --git a/core/domain/exp_domain_test.py b/core/domain/exp_domain_test.py
--- a/core/domain/exp_domain_test.py
+++ b/core/domain/exp_domain_test.py
@@ -3430,7 +3430,116 @@ class SchemaMigrationUnitTests(test_utils.GenericTestBase):
 title: Title
 """)
 
-    _LATEST_YAML_CONTENT = YAML_CONTENT_V30
+    YAML_CONTENT_V31 = ("""author_notes: ''
+auto_tts_enabled: true
+blurb: ''
+category: Category
+correctness_feedback_enabled: false
+init_state_name: (untitled state)
+language_code: en
+objective: ''
+param_changes: []
+param_specs: {}
+schema_version: 31
+states:
+  (untitled state):
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: ''
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+      feedback_1: {}
+    interaction:
+      answer_groups:
+      - outcome:
+          dest: END
+          feedback:
+            content_id: feedback_1
+            html: <p>Correct!</p>
+          labelled_as_correct: false
+          missing_prerequisite_skill_id: null
+          param_changes: []
+          refresher_exploration_id: null
+        rule_specs:
+        - inputs:
+            x: InputString
+          rule_type: Equals
+        tagged_misconception_id: null
+        training_data: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: (untitled state)
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+  END:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: <p>Congratulations, you have finished!</p>
+    content_ids_to_audio_translations:
+      content: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        recommendedExplorationIds:
+          value: []
+      default_outcome: null
+      hints: []
+      id: EndExploration
+      solution: null
+    param_changes: []
+  New state:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: ''
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: END
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+states_schema_version: 26
+tags: []
+title: Title
+""")
+
+    _LATEST_YAML_CONTENT = YAML_CONTENT_V31
 
     def test_load_from_v1(self):
         """Test direct loading from a v1 yaml file."""
@@ -3813,7 +3922,7 @@ class HTMLMigrationUnitTests(test_utils.GenericTestBase):
 """)
 
 # pylint: disable=line-too-long
-    YAML_CONTENT_V30_IMAGE_DIMENSIONS = ("""author_notes: ''
+    YAML_CONTENT_V31_IMAGE_DIMENSIONS = ("""author_notes: ''
 auto_tts_enabled: true
 blurb: ''
 category: category
@@ -3823,7 +3932,7 @@ class HTMLMigrationUnitTests(test_utils.GenericTestBase):
 objective: ''
 param_changes: []
 param_specs: {}
-schema_version: 30
+schema_version: 31
 states:
   Introduction:
     classifier_model_id: null
@@ -4008,7 +4117,7 @@ class HTMLMigrationUnitTests(test_utils.GenericTestBase):
       id: ItemSelectionInput
       solution: null
     param_changes: []
-states_schema_version: 25
+states_schema_version: 26
 tags: []
 title: title
 """)
@@ -4123,7 +4232,7 @@ class HTMLMigrationUnitTests(test_utils.GenericTestBase):
 title: Title
 """)
 
-    YAML_CONTENT_V30_WITH_IMAGE_CAPTION = ("""author_notes: ''
+    YAML_CONTENT_V31_WITH_IMAGE_CAPTION = ("""author_notes: ''
 auto_tts_enabled: true
 blurb: ''
 category: Category
@@ -4133,7 +4242,7 @@ class HTMLMigrationUnitTests(test_utils.GenericTestBase):
 objective: ''
 param_changes: []
 param_specs: {}
-schema_version: 30
+schema_version: 31
 states:
   (untitled state):
     classifier_model_id: null
@@ -4229,7 +4338,7 @@ class HTMLMigrationUnitTests(test_utils.GenericTestBase):
       id: TextInput
       solution: null
     param_changes: []
-states_schema_version: 25
+states_schema_version: 26
 tags: []
 title: Title
 """)
@@ -4246,7 +4355,7 @@ def test_load_from_v26_textangular(self):
             exploration = exp_domain.Exploration.from_yaml(
                 'eid', self.YAML_CONTENT_V26_TEXTANGULAR)
         self.assertEqual(
-            exploration.to_yaml(), self.YAML_CONTENT_V30_IMAGE_DIMENSIONS)
+            exploration.to_yaml(), self.YAML_CONTENT_V31_IMAGE_DIMENSIONS)
 
     def test_load_from_v27_without_image_caption(self):
         """Test direct loading from a v27 yaml file."""
@@ -4257,7 +4366,7 @@ def test_load_from_v27_without_image_caption(self):
             exploration = exp_domain.Exploration.from_yaml(
                 'eid', self.YAML_CONTENT_V27_WITHOUT_IMAGE_CAPTION)
         self.assertEqual(
-            exploration.to_yaml(), self.YAML_CONTENT_V30_WITH_IMAGE_CAPTION)
+            exploration.to_yaml(), self.YAML_CONTENT_V31_WITH_IMAGE_CAPTION)
 
 
 class ConversionUnitTests(test_utils.GenericTestBase):
diff --git a/core/domain/question_jobs_one_off_test.py b/core/domain/question_jobs_one_off_test.py
--- a/core/domain/question_jobs_one_off_test.py
+++ b/core/domain/question_jobs_one_off_test.py
@@ -116,12 +116,12 @@ def test_migration_job_converts_old_question(self):
         correctly and an old question is converted to new
         version.
         """
-        # Generate question with old(v25) state data.
-        self.save_new_question_with_state_data_schema_v25(
+        # Generate question with old(v26) state data.
+        self.save_new_question_with_state_data_schema_v26(
             self.QUESTION_ID, self.albert_id)
         question = (
             question_services.get_question_by_id(self.QUESTION_ID))
-        self.assertEqual(question.question_state_schema_version, 25)
+        self.assertEqual(question.question_state_schema_version, 26)
 
         # Start migration job.
         with self.swap(constants, 'ENABLE_NEW_STRUCTURE_EDITORS', True):
diff --git a/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip b/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip
Binary files a/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip and b/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip differ
diff --git a/core/tests/test_utils.py b/core/tests/test_utils.py
--- a/core/tests/test_utils.py
+++ b/core/tests/test_utils.py
@@ -179,7 +179,7 @@ class TestBase(unittest.TestCase):
         }
     }
 
-    VERSION_25_STATE_DICT = {
+    VERSION_26_STATE_DICT = {
         'content': {'content_id': u'content', 'html': u''},
         'param_changes': [],
         'content_ids_to_audio_translations': {
@@ -1477,10 +1477,10 @@ def save_new_question(
         question_services.add_question(owner_id, question)
         return question
 
-    def save_new_question_with_state_data_schema_v25(
+    def save_new_question_with_state_data_schema_v26(
             self, question_id, owner_id,
             language_code=constants.DEFAULT_LANGUAGE_CODE):
-        """Saves a new default question with a default version 25 state
+        """Saves a new default question with a default version 26 state
         data dictionary.
 
         This function should only be used for creating questions in tests
@@ -1501,10 +1501,10 @@ def save_new_question_with_state_data_schema_v25(
         question_services.create_new_question_rights(question_id, owner_id)
         question_model = question_models.QuestionModel(
             id=question_id,
-            question_state_data=self.VERSION_25_STATE_DICT,
+            question_state_data=self.VERSION_26_STATE_DICT,
             language_code=language_code,
             version=1,
-            question_state_schema_version=25
+            question_state_schema_version=26
         )
         question_model.commit(
             owner_id, 'New question created',