Repository: oppia/oppia

Issue:
[BUG]: Draft change server error re dest_if_really_stuck field.
### Describe the bug

When the new field `dest_if_really_stuck` was introduced, the state migration did not update the draft change list update function accordingly and this caused an exploration with drafts to break in production with the following error:
```
Traceback (most recent call last):
  File "/workspace/core/controllers/editor.py", line 171, in get
    version=version)
  File "/workspace/core/domain/exp_services.py", line 2294, in get_user_exploration_data
    get_exp_with_draft_applied(exploration_id, user_id))
  File "/workspace/core/domain/exp_services.py", line 2439, in get_exp_with_draft_applied
    exp_id, draft_change_list)
  File "/workspace/core/domain/exp_services.py", line 589, in apply_change_list
    raise e
  File "/workspace/core/domain/exp_services.py", line 426, in apply_change_list
    for answer_groups in change.new_value
  File "/workspace/core/domain/exp_services.py", line 426, in <listcomp>
    for answer_groups in change.new_value
  File "/workspace/core/domain/state_domain.py", line 160, in from_dict
    Outcome.from_dict(answer_group_dict['outcome']),
  File "/workspace/core/domain/state_domain.py", line 1605, in from_dict
    outcome_dict['dest_if_really_stuck'],
KeyError: 'dest_if_really_stuck'
```

### Steps To Reproduce

1. Create an exploration with state version < 51.
2. Make some changes and don't save (let auto-save create a draft)
3. Move to state version >= 51
4. Run the migration

### Expected Behavior

The migration should be applied to drafts as well and exploration should load correctly.

### Screenshots/Videos

_No response_

### What device are you using?

_No response_

### Operating System

_No response_

### What browsers are you seeing the problem on?

_No response_

### Browser version

_No response_

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/core/domain/draft_upgrade_services.py b/core/domain/draft_upgrade_services.py
--- a/core/domain/draft_upgrade_services.py
+++ b/core/domain/draft_upgrade_services.py
@@ -466,11 +466,9 @@ def _convert_states_v51_dict_to_v52_dict(
     def _convert_states_v50_dict_to_v51_dict(
         cls, draft_change_list: List[exp_domain.ExplorationChange]
     ) -> List[exp_domain.ExplorationChange]:
-        """Converts from version 50 to 51. Version 51 adds a new
-        dest_if_really_stuck field to Outcome class to redirect learners
-        to a state for strengthening concepts when they get really stuck. As
-        this is a new property and therefore doesn't affect any pre-existing
-        drafts, there should be no changes to drafts.
+        """Converts draft change list from state version 50 to 51. Adds
+        a new field dest_if_really_stuck to Outcome class to direct the
+        learner to a custom revision state.
 
         Args:
             draft_change_list: list(ExplorationChange). The list of
@@ -479,6 +477,84 @@ def _convert_states_v50_dict_to_v51_dict(
         Returns:
             list(ExplorationChange). The converted draft_change_list.
         """
+        for i, change in enumerate(draft_change_list):
+            if (change.cmd == exp_domain.CMD_EDIT_STATE_PROPERTY and
+                    change.property_name ==
+                    exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS):
+                # Here we use cast because this 'if' condition forces change to
+                # have type EditExpStatePropertyInteractionAnswerGroupsCmd.
+                edit_interaction_answer_groups_cmd = cast(
+                    exp_domain.EditExpStatePropertyInteractionAnswerGroupsCmd,
+                    change
+                )
+                new_answer_groups_dicts = (
+                    edit_interaction_answer_groups_cmd.new_value
+                )
+                answer_group_dicts: List[state_domain.AnswerGroupDict] = []
+                for answer_group_dict in new_answer_groups_dicts:
+                    outcome_dict: state_domain.OutcomeDict = ({
+                        'dest': answer_group_dict['outcome']['dest'],
+                        'dest_if_really_stuck': None,
+                        'feedback': (
+                            answer_group_dict['outcome']['feedback']),
+                        'labelled_as_correct': (
+                            answer_group_dict
+                                ['outcome']['labelled_as_correct']),
+                        'param_changes': (
+                            answer_group_dict['outcome']['param_changes']),
+                        'refresher_exploration_id': (
+                            answer_group_dict
+                                ['outcome']['refresher_exploration_id']),
+                        'missing_prerequisite_skill_id': (
+                            answer_group_dict['outcome']
+                                ['missing_prerequisite_skill_id'])
+                    })
+                    answer_group_dicts.append({
+                        'rule_specs': answer_group_dict['rule_specs'],
+                        'outcome': outcome_dict,
+                        'training_data': answer_group_dict['training_data'],
+                        'tagged_skill_misconception_id': None
+                    })
+                draft_change_list[i] = exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name': (
+                        exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS),
+                    'state_name': change.state_name,
+                    'new_value': answer_group_dicts
+                })
+            elif (change.property_name ==
+                  exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME and
+                  change.cmd == exp_domain.CMD_EDIT_STATE_PROPERTY):
+                # Here we use cast because this 'elif' condition forces change
+                # to have type EditExpStatePropertyInteractionDefaultOutcomeCmd.
+                edit_interaction_default_outcome_cmd = cast(
+                    exp_domain.EditExpStatePropertyInteractionDefaultOutcomeCmd,
+                    change
+                )
+                new_default_outcome_dict = (
+                    edit_interaction_default_outcome_cmd.new_value
+                )
+                default_outcome_dict: state_domain.OutcomeDict = ({
+                    'dest': new_default_outcome_dict['dest'],
+                    'dest_if_really_stuck': None,
+                    'feedback': new_default_outcome_dict['feedback'],
+                    'labelled_as_correct': (
+                        new_default_outcome_dict['labelled_as_correct']),
+                    'param_changes': (
+                        new_default_outcome_dict['param_changes']),
+                    'refresher_exploration_id': (
+                        new_default_outcome_dict['refresher_exploration_id']),
+                    'missing_prerequisite_skill_id': (
+                        new_default_outcome_dict
+                            ['missing_prerequisite_skill_id'])
+                })
+                draft_change_list[i] = exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name': (
+                        exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
+                    'state_name': change.state_name,
+                    'new_value': default_outcome_dict
+                })
         return draft_change_list
 
     @classmethod


Test patch:
diff --git a/core/domain/draft_upgrade_services_test.py b/core/domain/draft_upgrade_services_test.py
--- a/core/domain/draft_upgrade_services_test.py
+++ b/core/domain/draft_upgrade_services_test.py
@@ -456,33 +456,153 @@ def test_convert_states_v50_dict_to_v51_dict(self) -> None:
         draft_change_list_v50 = [
             exp_domain.ExplorationChange({
                 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
-                'state_name': 'Intro',
-                'property_name': 'content',
-                'new_value': 'new value'
+                'property_name': 'answer_groups',
+                'state_name': 'State 1',
+                'new_value': [{
+                    'rule_specs': [{
+                        'rule_type': 'Equals',
+                        'inputs': {'x': [
+                            '<p>This is value1 for ItemSelection</p>'
+                        ]}
+                    }, {
+                        'rule_type': 'Equals',
+                        'inputs': {'x': [
+                            '<p>This is value2 for ItemSelection</p>'
+                        ]}
+                    }],
+                    'outcome': {
+                        'dest': 'Introduction',
+                        'feedback': {
+                            'content_id': 'feedback',
+                            'html': '<p>Outcome for state1</p>'
+                        },
+                        'param_changes': [],
+                        'labelled_as_correct': False,
+                        'refresher_exploration_id': None,
+                        'missing_prerequisite_skill_id': None
+                    },
+                    'training_data': [],
+                    'tagged_misconception_id': None
+                }]
+            })
+        ]
+        # Version 51 adds the dest_if_really_stuck field.
+        expected_draft_change_list_v51 = [
+            exp_domain.ExplorationChange({
+                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                'property_name': 'answer_groups',
+                'state_name': 'State 1',
+                'new_value': [{
+                    'rule_specs': [{
+                        'rule_type': 'Equals',
+                        'inputs': {'x': [
+                            '<p>This is value1 for ItemSelection</p>'
+                        ]}
+                    }, {
+                        'rule_type': 'Equals',
+                        'inputs': {'x': [
+                            '<p>This is value2 for ItemSelection</p>'
+                        ]}
+                    }],
+                    'outcome': {
+                        'dest': 'Introduction',
+                        'dest_if_really_stuck': None,
+                        'feedback': {
+                            'content_id': 'feedback',
+                            'html': '<p>Outcome for state1</p>'
+                        },
+                        'param_changes': [],
+                        'labelled_as_correct': False,
+                        'refresher_exploration_id': None,
+                        'missing_prerequisite_skill_id': None
+                    },
+                    'training_data': [],
+                    'tagged_skill_misconception_id': None
+                }]
             })
         ]
         # Migrate exploration to state schema version 51.
         self.create_and_migrate_new_exploration('50', '51')
+        # Migrate the draft change list's state schema to the migrated
+        # exploration's schema.
         migrated_draft_change_list_v51 = (
             draft_upgrade_services.try_upgrading_draft_to_exp_version(
                 draft_change_list_v50, 1, 2, self.EXP_ID)
         )
-        # Change draft change lists into a list of dicts so that it is
-        # easy to compare the whole draft change list.
         # Ruling out the possibility of None for mypy type checking.
         assert migrated_draft_change_list_v51 is not None
-        draft_change_list_v50_dict_list = [
-            change.to_dict() for change in draft_change_list_v50
+        # Change draft change lists into a list of dicts so that it is
+        # easy to compare the whole draft change list.
+        expected_draft_change_list_v51_dict_list = [
+            change.to_dict() for change in expected_draft_change_list_v51
         ]
-
         migrated_draft_change_list_v51_dict_list = [
             change.to_dict() for change in migrated_draft_change_list_v51
         ]
-
         self.assertEqual(
-            draft_change_list_v50_dict_list,
+            expected_draft_change_list_v51_dict_list,
             migrated_draft_change_list_v51_dict_list)
 
+        draft_change_list_v50_2 = [
+            exp_domain.ExplorationChange({
+                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                'state_name': 'Intro',
+                'property_name': 'default_outcome',
+                'new_value': {
+                    'param_changes': [],
+                    'feedback': {
+                        'content_id': 'feedback',
+                        'html': '<p>Content</p>'
+                    },
+                    'dest': 'Introduction',
+                    'refresher_exploration_id': None,
+                    'missing_prerequisite_skill_id': None,
+                    'labelled_as_correct': False
+                }
+            })
+        ]
+        # Version 51 adds the dest_if_really_stuck field.
+        expected_draft_change_list_v51_2 = [
+            exp_domain.ExplorationChange({
+                'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                'state_name': 'Intro',
+                'property_name': 'default_outcome',
+                'new_value': {
+                    'param_changes': [],
+                    'feedback': {
+                        'content_id': 'feedback',
+                        'html': '<p>Content</p>'
+                    },
+                    'dest': 'Introduction',
+                    'dest_if_really_stuck': None,
+                    'refresher_exploration_id': None,
+                    'missing_prerequisite_skill_id': None,
+                    'labelled_as_correct': False
+                }
+            })
+        ]
+        # Migrate exploration to state schema version 51.
+        self.create_and_migrate_new_exploration('50', '51')
+        # Migrate the draft change list's state schema to the migrated
+        # exploration's schema.
+        migrated_draft_change_list_v51_2 = (
+            draft_upgrade_services.try_upgrading_draft_to_exp_version(
+                draft_change_list_v50_2, 1, 2, self.EXP_ID)
+        )
+        # Ruling out the possibility of None for mypy type checking.
+        assert migrated_draft_change_list_v51_2 is not None
+        # Change draft change lists into a list of dicts so that it is
+        # easy to compare the whole draft change list.
+        expected_draft_change_list_v51_dict_list_2 = [
+            change.to_dict() for change in expected_draft_change_list_v51_2
+        ]
+        migrated_draft_change_list_v51_dict_list_2 = [
+            change.to_dict() for change in migrated_draft_change_list_v51_2
+        ]
+        self.assertEqual(
+            expected_draft_change_list_v51_dict_list_2,
+            migrated_draft_change_list_v51_dict_list_2)
+
     def test_convert_states_v49_dict_to_v50_dict(self) -> None:
         draft_change_list_v49 = [
             exp_domain.ExplorationChange({