Repository: oppia/oppia

Issue:
[BUG]: [IMPORTANT] Edits made during reviewing are not saved
### Describe the bug

While reviewing a problem, I made some edits to it and then accepted it. However, when I check the skill editor in the admin dashboard, the problem that shows up is the non-edited version.

### URL of the page where the issue is observed.

https://www.oppia.org/skill_editor/9oZSgyxbbcmK#/questions

### Steps To Reproduce

1. Go to review dashboard, and review a question (Take note of what skill it is)
2. Make edits to the question, then submit it (Take note of original version and what edits were made)
3. Message will show up that submission has been accepted
4. Go to topics and skills dashboard (requires elevated permissions)
5. Under topics, go to the topic corresponds to the question that you reviewed and click on it
6. Scroll down to subtopics and select the subtopic that corresponds to the question, which will open up a list of specific skills
7. Select the corresponding skill
8. Click on the book icon in the middle at the topic, which should bring you to the submitted question for that skill
9. Find the question that you reviewed under the list of questions
10. Open up the question and look through it
11. It will be the unedited version

### Expected Behavior

I expect the edits made while reviewing to be saved, so the question should show up as edited in the skill editor.

### Screenshots/Videos

In this video, I made edits to a submitted question and then accepted the question. However, when I went to check the skill editor, the question showed up as the original version without any of my edits.

https://github.com/oppia/oppia/assets/91704161/e512f05b-5c1d-47f4-82b4-28f57dad221b



### What device are you using?

Desktop

### Operating System

Windows

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

Other

### Browser version

117.0.22553.92 (Avast Secure Browser)

### Additional context

I checked other skills that I reviewed, and from what I remember, the changes were not saved for those skills either.

### Tips for developers

Before tackling the bug, please use `git bisect` (see https://git-scm.com/docs/git-bisect) to investigate which PR caused it (you only need to go back as far as commit https://github.com/oppia/oppia/commits/9a334e9). If you find the PR, leave a comment on this issue pointing to it, or just say that it happened before commit 9a334e9 if you could reproduce it there. This will help us fix the issue by reverting the faulty PR.

Also, if this is your first issue, please make sure to follow https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue and https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#setting-things-up before claiming it. Thanks!



Discussion:
Hi @RH809, I visited the given URL but faced an error shown below
![Screenshot 2023-10-04 232723](https://github.com/oppia/oppia/assets/128630418/32bb6131-98b2-4d5e-b17d-f0bc49a2df9f)
Can you help me out with this so that I can work on this bug.
Thanks.
Sean's investigation notes:

> On my investigation I saw the following in the console:
> ![Screenshot from 2023-10-05 14-02-08](https://github.com/oppia/oppia/assets/9004726/e11e8349-cf18-41d4-a51e-261d16bbd0b1)
> 
> and the following in the server:
> ![Screenshot from 2023-10-05 14-02-02](https://github.com/oppia/oppia/assets/9004726/39bd36a7-7d00-4d84-9e87-173745488220)
> 
> My suspicion is that the edit to the question doesn't get saved, but still the original question gets accepted. I think two fixes are needed:
> 1. If there's an error with the question editing, the behaviour should be to throw an error and reject the request (and not proceed with acceptance).
> 2. The schema validation issue needs to be addressed (the only thing I did when editing the question I eventually accepted was to modify the wording of a hint, so that shouldn't really cause any schema issues).
Schema validation error seems to be raised here: https://github.com/oppia/oppia/blob/develop/core/domain/state_domain.py#L245
For fix 1, we'll need to handle question suggestion update errors [here](https://github.com/oppia/oppia/blob/develop/core/templates/pages/contributor-dashboard-page/modal-templates/question-suggestion-editor-modal.component.ts#L153), and make sure to block suggestion acceptance.

Resolving patch:
diff --git a/core/controllers/domain_objects_validator.py b/core/controllers/domain_objects_validator.py
--- a/core/controllers/domain_objects_validator.py
+++ b/core/controllers/domain_objects_validator.py
@@ -264,6 +264,29 @@ def validate_state_dict(
     return state_dict
 
 
+def validate_question_state_dict(
+    question_state_dict: state_domain.StateDict
+) -> state_domain.StateDict:
+    """Validates state dict for a question.
+
+    Args:
+        question_state_dict: dict. The dict representation of State object for
+            a question.
+
+    Returns:
+        State. The question_state_dict after validation.
+    """
+    question_state_object = state_domain.State.from_dict(question_state_dict)
+    # 'tagged_skill_misconception_id_required' is not None when a state is part
+    # of a Question object that tests a particular skill.
+    question_state_object.validate(
+        exp_param_specs_dict=None,
+        allow_null_interaction=True,
+        tagged_skill_misconception_id_required=True)
+
+    return question_state_dict
+
+
 def validate_email_dashboard_data(
     data: Dict[str, Optional[Union[bool, int]]]
 ) -> Dict[str, Optional[Union[bool, int]]]:
diff --git a/core/controllers/suggestion.py b/core/controllers/suggestion.py
--- a/core/controllers/suggestion.py
+++ b/core/controllers/suggestion.py
@@ -1093,7 +1093,7 @@ class UpdateQuestionSuggestionHandler(
                 'schema': {
                     'type': 'object_dict',
                     'validation_method': (
-                        domain_objects_validator.validate_state_dict
+                        domain_objects_validator.validate_question_state_dict
                     )
                 }
             },


Test patch:
diff --git a/core/controllers/domain_objects_validator_test.py b/core/controllers/domain_objects_validator_test.py
--- a/core/controllers/domain_objects_validator_test.py
+++ b/core/controllers/domain_objects_validator_test.py
@@ -32,7 +32,7 @@
 from core.domain import translation_domain
 from core.tests import test_utils
 
-from typing import Dict, Mapping, Optional, Union
+from typing import Dict, List, Mapping, Optional, Union
 
 
 class ValidateSuggestionChangeTests(test_utils.GenericTestBase):
@@ -457,6 +457,112 @@ def test_invalid_object_raises_exception(self) -> None:
             domain_objects_validator.validate_state_dict(invalid_state_dict)  # type: ignore[arg-type]
 
 
+class ValidateQuestionStateDict(test_utils.GenericTestBase):
+    """Tests to validate question_state_dict coming from frontend."""
+
+    def test_valid_object_raises_no_exception(self) -> None:
+        choices_subtitled_dicts: List[state_domain.SubtitledHtmlDict] = [
+            {'html': '<p>1</p>', 'content_id': 'ca_choices_2'},
+            {'html': '<p>2</p>', 'content_id': 'ca_choices_3'}
+        ]
+        question_state_dict: state_domain.StateDict = {
+            'content': {'html': '', 'content_id': 'content_0'},
+            'classifier_model_id': None,
+            'linked_skill_id': None,
+            'interaction': {
+                'answer_groups': [{
+                    'rule_specs': [{'rule_type': 'Equals', 'inputs': {'x': 0}}],
+                    'outcome': {
+                        'dest': None,
+                        'dest_if_really_stuck': None,
+                        'feedback': {'html': '', 'content_id': 'feedback_4'},
+                        'labelled_as_correct': True,
+                        'param_changes': [],
+                        'refresher_exploration_id': None,
+                        'missing_prerequisite_skill_id': None
+                    },
+                    'training_data': [],
+                    'tagged_skill_misconception_id': None
+                },
+                {
+                    'rule_specs': [{'rule_type': 'Equals', 'inputs': {'x': 1}}],
+                    'outcome': {
+                        'dest': None,
+                        'dest_if_really_stuck': None,
+                        'feedback': {'html': '', 'content_id': 'feedback_5'},
+                        'labelled_as_correct': False,
+                        'param_changes': [],
+                        'refresher_exploration_id': None,
+                        'missing_prerequisite_skill_id': None
+                    },
+                    'training_data': [],
+                    'tagged_skill_misconception_id': 'pBi3XobmC4zq-0'
+                }],
+                'confirmed_unclassified_answers': [],
+                'customization_args': {
+                    'showChoicesInShuffledOrder': {'value': True},
+                    'choices': {'value': choices_subtitled_dicts}
+                },
+                'default_outcome': {
+                    'dest': None,
+                    'dest_if_really_stuck': None,
+                    'feedback': {
+                        'html': '', 'content_id': 'default_outcome_1'
+                    },
+                    'labelled_as_correct': False,
+                    'param_changes': [],
+                    'refresher_exploration_id': None,
+                    'missing_prerequisite_skill_id': None
+                },
+                'hints': [],
+                'id': 'MultipleChoiceInput',
+                'solution': None
+            },
+            'param_changes': [],
+            'recorded_voiceovers': {
+                'voiceovers_mapping': {
+                    'content_0': {},
+                    'default_outcome_1': {},
+                    'ca_choices_2': {},
+                    'ca_choices_3': {},
+                    'feedback_4': {},
+                    'feedback_5': {}
+                }
+            },
+            'solicit_answer_details': False,
+            'card_is_checkpoint': False
+        }
+        domain_objects_validator.validate_question_state_dict(
+            question_state_dict)
+
+    def test_invalid_object_raises_exception(self) -> None:
+        invalid_question_state_dict: Dict[
+            str,
+            Optional[
+                Union[int, bool, Dict[str, Dict[str, Dict[str, str]]]]
+            ]
+        ] = {
+            'classifier_model_id': None,
+            'written_questions': {
+                'questions_mapping': {
+                    'content': {},
+                    'default_outcome': {},
+                    'ca_placeholder_0': {}
+                }
+            },
+            'next_content_id_index': 1,
+            'card_is_checkpoint': False,
+            'solicit_answer_details': False
+        }
+        # TODO(#13059): Here we use MyPy ignore because after we fully type the
+        # codebase we plan to get rid of the tests that intentionally test wrong
+        # inputs that we can normally catch by typing.
+        # The error is representing the keyerror.
+        with self.assertRaisesRegex(Exception, 'content'):
+            domain_objects_validator.validate_question_state_dict(
+                invalid_question_state_dict)  # type: ignore[arg-type]
+
+
 class ValidateSuggestionImagesTests(test_utils.GenericTestBase):
     """Tests to validate suggestion images coming from frontend."""