Repository: oppia/oppia

Issue:
Add a "correct" field to answer groups
Answer groups should have an extra field (added as a key in the dictionary, in addition to the current rule_specs and outcome) which is a boolean value that specifies whether this answer is correct. This will be used in the upcoming Questions feature, as well as in the simple editor. 

The 'correct' field in answer groups doesn't affect current functionality, but will in the future be used to indicate whether to move on to the next question in the Question player, or give users statistics about how often they get answers correct.

In all current explorations and answer groups, all answer groups will have 'correct' set to false. Answer groups can have more than one correct answer.

Implementation todos:
- [x] add 'correct' field to answer groups by modifying the `to_dict` and `from_dict` functions in `exp_domain.AnswerGroup`
- [x] validate the type of 'correct' in `exp_domain.AnswerGroup`
- [x] modify answer groups in the frontend in `AnswerGroupObjectFactory.js`
- [x] set 'correct' to `false` by default in the exploration editor


Discussion:
/cc @tjiang11 @seanlip 

Resolving patch:
diff --git a/core/controllers/editor.py b/core/controllers/editor.py
--- a/core/controllers/editor.py
+++ b/core/controllers/editor.py
@@ -339,7 +339,6 @@ def put(self, exploration_id):
 
         commit_message = self.payload.get('commit_message')
         change_list = self.payload.get('change_list')
-
         try:
             exp_services.update_exploration(
                 self.user_id, exploration_id, change_list, commit_message)
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
@@ -504,6 +504,7 @@ def to_dict(self):
             'rule_specs': [rule_spec.to_dict()
                            for rule_spec in self.rule_specs],
             'outcome': self.outcome.to_dict(),
+            'correct': self.correct,
         }
 
     @classmethod
@@ -511,14 +512,16 @@ def from_dict(cls, answer_group_dict):
         return cls(
             Outcome.from_dict(answer_group_dict['outcome']),
             [RuleSpec.from_dict(rs) for rs in answer_group_dict['rule_specs']],
+            answer_group_dict['correct'],
         )
 
-    def __init__(self, outcome, rule_specs):
+    def __init__(self, outcome, rule_specs, correct):
         self.rule_specs = [RuleSpec(
             rule_spec.rule_type, rule_spec.inputs
         ) for rule_spec in rule_specs]
 
         self.outcome = outcome
+        self.correct = correct
 
     def validate(self, interaction, exp_param_specs_dict):
         """Rule validation.
@@ -532,8 +535,11 @@ def validate(self, interaction, exp_param_specs_dict):
                 % self.rule_specs)
         if len(self.rule_specs) < 1:
             raise utils.ValidationError(
-                'There must be at least one rule for each answer group.'
-                % self.rule_specs)
+                'There must be at least one rule for each answer group.')
+        if not isinstance(self.correct, bool):
+            raise utils.ValidationError(
+                'The "correct" field should be a boolean, received %s'
+                % self.correct)
 
         seen_classifier_rule = False
         for rule_spec in self.rule_specs:
@@ -1181,7 +1187,7 @@ def update_interaction_answer_groups(self, answer_groups_list):
                     'received %s' % rule_specs_list)
 
             answer_group = AnswerGroup(Outcome.from_dict(
-                answer_group_dict['outcome']), [])
+                answer_group_dict['outcome']), [], answer_group_dict['correct'])
             answer_group.outcome.feedback = [
                 html_cleaner.clean(feedback)
                 for feedback in answer_group.outcome.feedback]
@@ -1399,6 +1405,7 @@ def from_dict(
                         'inputs': rule_spec['inputs'],
                         'rule_type': rule_spec['rule_type'],
                     } for rule_spec in group['rule_specs']],
+                    'correct': False,
                 })
                 for group in idict['answer_groups']]
 
@@ -2250,7 +2257,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 = 11
+    CURRENT_EXP_SCHEMA_VERSION = 12
     LAST_UNTITLED_SCHEMA_VERSION = 9
 
     @classmethod
@@ -2425,6 +2432,18 @@ def _convert_v10_dict_to_v11_dict(cls, exploration_dict):
 
         return exploration_dict
 
+    @classmethod
+    def _convert_v11_dict_to_v12_dict(cls, exploration_dict):
+        """Converts a v11 exploration dict into a v12 exploration dict."""
+
+        exploration_dict['schema_version'] = 12
+        states = exploration_dict['states']
+        for state in states.itervalues():
+            answer_groups = state['interaction']['answer_groups']
+            for answer_group in answer_groups:
+                answer_group['correct'] = False
+        return exploration_dict
+
     @classmethod
     def _migrate_to_latest_yaml_version(
             cls, yaml_content, title=None, category=None):
@@ -2495,6 +2514,12 @@ def _migrate_to_latest_yaml_version(
                 exploration_dict)
             exploration_schema_version = 11
 
+        if exploration_schema_version == 11:
+            exploration_dict = cls._convert_v11_dict_to_v12_dict(
+                exploration_dict)
+            exploration_schema_version = 12
+
+
         return (exploration_dict, initial_schema_version)
 
     @classmethod
diff --git a/core/domain/exp_services.py b/core/domain/exp_services.py
--- a/core/domain/exp_services.py
+++ b/core/domain/exp_services.py
@@ -638,7 +638,6 @@ def apply_change_list(exploration_id, change_list):
     try:
         changes = [exp_domain.ExplorationChange(change_dict)
                    for change_dict in change_list]
-
         for change in changes:
             if change.cmd == exp_domain.CMD_ADD_STATE:
                 exploration.add_states([change.state_name])


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
@@ -350,7 +350,8 @@ def test_validation(self):
                         'training_data': ['Test']
                     },
                     'rule_type': 'FuzzyMatches'
-                }]
+                }],
+                'correct': False,
             })
         )
 
@@ -381,7 +382,8 @@ def test_validation(self):
                         'x': 'Test'
                     },
                     'rule_type': 'Contains'
-                }]
+                }],
+                'correct': False,
             })
         )
         exploration.validate()
@@ -1823,7 +1825,89 @@ class SchemaMigrationUnitTests(test_utils.GenericTestBase):
 tags: []
 title: Title
 """)
-    _LATEST_YAML_CONTENT = YAML_CONTENT_V11
+    YAML_CONTENT_V12 = ("""author_notes: ''
+blurb: ''
+category: Category
+init_state_name: (untitled state)
+language_code: en
+objective: ''
+param_changes: []
+param_specs: {}
+schema_version: 12
+skin_customizations:
+  panels_contents:
+    bottom: []
+states:
+  (untitled state):
+    classifier_model_id: null
+    content:
+    - type: text
+      value: ''
+    interaction:
+      answer_groups:
+      - correct: false
+        outcome:
+          dest: END
+          feedback:
+          - Correct!
+          param_changes: []
+        rule_specs:
+        - inputs:
+            x: InputString
+          rule_type: Equals
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: (untitled state)
+        feedback: []
+        param_changes: []
+      fallbacks: []
+      id: TextInput
+    param_changes: []
+  END:
+    classifier_model_id: null
+    content:
+    - type: text
+      value: Congratulations, you have finished!
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        recommendedExplorationIds:
+          value: []
+      default_outcome: null
+      fallbacks: []
+      id: EndExploration
+    param_changes: []
+  New state:
+    classifier_model_id: null
+    content:
+    - type: text
+      value: ''
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: END
+        feedback: []
+        param_changes: []
+      fallbacks: []
+      id: TextInput
+    param_changes: []
+states_schema_version: 8
+tags: []
+title: Title
+""")
+    _LATEST_YAML_CONTENT = YAML_CONTENT_V12
 
     def test_load_from_v1(self):
         """Test direct loading from a v1 yaml file."""
@@ -1891,6 +1975,12 @@ def test_load_from_v11(self):
             'eid', self.YAML_CONTENT_V11)
         self.assertEqual(exploration.to_yaml(), self._LATEST_YAML_CONTENT)
 
+    def test_load_from_v12(self):
+        """Test direct loading from a v12 yaml file."""
+        exploration = exp_domain.Exploration.from_yaml(
+            'eid', self.YAML_CONTENT_V12)
+        self.assertEqual(exploration.to_yaml(), self._LATEST_YAML_CONTENT)
+
 class ConversionUnitTests(test_utils.GenericTestBase):
     """Test conversion methods."""
 
diff --git a/core/domain/exp_services_test.py b/core/domain/exp_services_test.py
--- a/core/domain/exp_services_test.py
+++ b/core/domain/exp_services_test.py
@@ -923,6 +923,7 @@ def setUp(self):
                 'feedback': ['Try again'],
                 'param_changes': []
             },
+            'correct': False,
         }]
         # Default outcome specification for an interaction.
         self.interaction_default_outcome = {
diff --git a/core/domain/stats_services_test.py b/core/domain/stats_services_test.py
--- a/core/domain/stats_services_test.py
+++ b/core/domain/stats_services_test.py
@@ -328,7 +328,7 @@ def test_no_improvement_flag_hit(self):
         init_interaction = exp.init_state.interaction
         init_interaction.answer_groups.append(exp_domain.AnswerGroup(
             exp_domain.Outcome(exp.init_state_name, [], {}),
-            [not_default_rule_spec]))
+            [not_default_rule_spec], False))
         init_interaction.default_outcome = exp_domain.Outcome(
             'End', [], {})
         exp_services._save_exploration(  # pylint: disable=protected-access
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/protractor/historyTab.js b/core/tests/protractor/historyTab.js
--- a/core/tests/protractor/historyTab.js
+++ b/core/tests/protractor/historyTab.js
@@ -78,74 +78,78 @@ describe('Exploration history', function() {
         highlighted: true
       },
       7: {
-        text: '  - outcome:',
+        text: '  - correct: false',
         highlighted: true
       },
       8: {
-        text: '      dest: second',
+        text: '    outcome:',
         highlighted: true
       },
       9: {
-        text: '      feedback: []',
+        text: '      dest: second',
         highlighted: true
       },
       10: {
-        text: '      param_changes: []',
+        text: '      feedback: []',
         highlighted: true
       },
       11: {
-        text: '    rule_specs:',
+        text: '      param_changes: []',
         highlighted: true
       },
       12: {
-        text: '    - inputs:',
+        text: '    rule_specs:',
         highlighted: true
       },
       13: {
-        text: '        x: 6',
+        text: '    - inputs:',
         highlighted: true
       },
       14: {
-        text: '      rule_type: Equals',
+        text: '        x: 6',
         highlighted: true
       },
       15: {
+        text: '      rule_type: Equals',
+        highlighted: true
+      },
+      16: {
         text: '  confirmed_unclassified_answers: []',
         highlighted: false
       },
-      16: {
+      17: {
         text: '  customization_args: {}',
         highlighted: false
       },
-      17: {
+      18: {
         text: '  default_outcome:',
         highlighted: false
       },
-      18: {
+      19: {
         text: '    dest: first',
         highlighted: true
       },
-      19: {
+      20: {
         text: '    feedback: []',
         highlighted: false
       },
-      20: {
+      21: {
         text: '    param_changes: []',
         highlighted: false
       },
-      21: {
+      22: {
         text: '  fallbacks: []',
         highlighted: false
       },
-      22: {
+      23: {
         text: '  id: NumericInput',
         highlighted: true
       },
-      23: {
+      24: {
         text: 'param_changes: []',
         highlighted: false
       },
-      24: {
+      25: {
         text: ' ',
         highlighted: false
       }