Repository: oppia/oppia

Issue:
[BUG]: Cannot edit question 
### Describe the bug

Existing question cannot be edited on test server. Raising error "Found extra args: ['version']." This happens for other questions too.


### Steps To Reproduce

1. Go to https://www.oppiatestserver.org/skill_editor/t5BkwtXRRmpu
2. Try to edit "s" "NumericInput" question
3. Save the chages
4. See error

### Expected Behavior

_No response_

### Screenshots/Videos

![image](https://user-images.githubusercontent.com/10142938/218104528-17a30ab0-a19f-4297-b5d3-98ce06c38fb0.png)

### What device are you using?

Desktop

### Operating System

Linux

### What browsers are you seeing the problem on?

Firefox

### Browser version

113

### Additional context

_No response_
[BUG]: Cannot edit question 
### Describe the bug

Existing question cannot be edited on test server. Raising error "Found extra args: ['version']." This happens for other questions too.


### Steps To Reproduce

1. Go to https://www.oppiatestserver.org/skill_editor/t5BkwtXRRmpu
2. Try to edit "s" "NumericInput" question
3. Save the chages
4. See error

### Expected Behavior

_No response_

### Screenshots/Videos

![image](https://user-images.githubusercontent.com/10142938/218104528-17a30ab0-a19f-4297-b5d3-98ce06c38fb0.png)

### What device are you using?

Desktop

### Operating System

Linux

### What browsers are you seeing the problem on?

Firefox

### Browser version

113

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/core/controllers/question_editor.py b/core/controllers/question_editor.py
--- a/core/controllers/question_editor.py
+++ b/core/controllers/question_editor.py
@@ -263,6 +263,7 @@ class EditableQuestionDataHandlerNormalizedPayloadDict(TypedDict):
     normalized_payload dictionary.
     """
 
+    version: int
     commit_message: str
     change_list: List[question_domain.QuestionChange]
 
@@ -283,6 +284,11 @@ class EditableQuestionDataHandler(
     HANDLER_ARGS_SCHEMAS = {
         'GET': {},
         'PUT': {
+            'version': {
+                'schema': {
+                    'type': 'int'
+                }
+            },
             'commit_message': {
                 'schema': {
                     'type': 'basestring',
@@ -329,16 +335,18 @@ def put(self, question_id: str) -> None:
         assert self.normalized_payload is not None
         commit_message = self.normalized_payload['commit_message']
         change_list = self.normalized_payload['change_list']
-
+        version = self.normalized_payload['version']
         for change in change_list:
             if (
                     change.cmd ==
                     question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION):
-                raise self.InvalidInputException
+                raise self.InvalidInputException(
+                    'Cannot create a new fully specified question'
+                )
 
         question_services.update_question(
             self.user_id, question_id, change_list,
-            commit_message)
+            commit_message, version)
 
         question_dict = question_services.get_question_by_id(
             question_id).to_dict()
diff --git a/core/domain/question_services.py b/core/domain/question_services.py
--- a/core/domain/question_services.py
+++ b/core/domain/question_services.py
@@ -704,7 +704,9 @@ def _save_question(
     change_list: List[question_domain.QuestionChange],
     commit_message: str
 ) -> None:
-    """Validates a question and commits it to persistent storage.
+    """Validates a question and commits it to persistent storage. If
+    successful, increments the version number of the incoming question domain
+    object by 1.
 
     Args:
         committer_id: str. The id of the user who is performing the update
@@ -743,7 +745,8 @@ def update_question(
     committer_id: str,
     question_id: str,
     change_list: List[question_domain.QuestionChange],
-    commit_message: str
+    commit_message: str,
+    version: Optional[int] = None
 ) -> None:
     """Updates a question. Commits changes.
 
@@ -756,14 +759,28 @@ def update_question(
             question.
         commit_message: str. A description of changes made to the
             question.
+        version: int. Version of the question coming from payload while
+            updating question.
 
     Raises:
         ValueError. No commit message was provided.
+        Exception. Version of the incoming question and current question
+            differs.
     """
     if not commit_message:
         raise ValueError(
             'Expected a commit message, received none.')
     updated_question = apply_change_list(question_id, change_list)
+    if (version is not None) and (version < updated_question.version):
+        raise Exception(
+            'Unexpected error: trying to update version %s of question '
+            'from version %s. Please reload the page and try again.'
+            % (version, updated_question.version))
+    if (version is not None) and (version > updated_question.version):
+        raise Exception(
+            'Trying to update version %s of question from version %s, '
+            'which is too old. Please reload the page and try again.'
+            % (version, updated_question.version))
     _save_question(
         committer_id, updated_question, change_list, commit_message)
     create_question_summary(question_id)


Test patch:
diff --git a/core/controllers/question_editor_test.py b/core/controllers/question_editor_test.py
--- a/core/controllers/question_editor_test.py
+++ b/core/controllers/question_editor_test.py
@@ -745,7 +745,8 @@ def test_put_with_long_commit_message_fails(self) -> None:
         }]
         payload = {
             'change_list': change_list,
-            'commit_message': ('a' * (constants.MAX_COMMIT_MESSAGE_LENGTH + 1))
+            'commit_message': ('a' * (constants.MAX_COMMIT_MESSAGE_LENGTH + 1)),
+            'version': 2
         }
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
@@ -779,7 +780,8 @@ def test_put_with_admin_email_allows_question_editing(self) -> None:
         }]
         payload = {
             'change_list': change_list,
-            'commit_message': 'update question data'
+            'commit_message': 'update question data',
+            'version': 1
         }
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
@@ -826,7 +828,8 @@ def test_put_with_topic_manager_email_allows_question_editing(self) -> None:
         }]
         payload = {
             'change_list': change_list,
-            'commit_message': 'update question data'
+            'commit_message': 'update question data',
+            'version': 1
         }
 
         self.login(self.TOPIC_MANAGER_EMAIL)
@@ -871,13 +874,17 @@ def test_put_with_creating_new_fully_specified_question_returns_400(
         }]
         payload = {
             'change_list': change_list,
-            'commit_message': 'update question data'
+            'commit_message': 'update question data',
+            'version': 1
         }
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
-        self.put_json(
+        response_json = self.put_json(
             '%s/%s' % (
                 feconf.QUESTION_EDITOR_DATA_URL_PREFIX, self.question_id),
             payload,
             csrf_token=csrf_token, expected_status_int=400)
+        self.assertEqual(
+            response_json['error'],
+            'Cannot create a new fully specified question')
         self.logout()
diff --git a/core/domain/question_services_test.py b/core/domain/question_services_test.py
--- a/core/domain/question_services_test.py
+++ b/core/domain/question_services_test.py
@@ -667,6 +667,33 @@ def _mock_logging_function(msg: str, *args: str) -> None:
             'object has no attribute \'cmd\' %s '
             'invalid_change_list' % self.question_id)
 
+    def test_cannot_update_question_with_mismatch_of_versions(
+        self
+    ) -> None:
+        changelist = [question_domain.QuestionChange({
+            'cmd': 'update_question_property',
+            'property_name': 'language_code',
+            'new_value': 'bn',
+            'old_value': 'en'
+        })]
+        with self.assertRaisesRegex(
+            Exception,
+            'Trying to update version 2 of question from version 1, '
+            'which is too old. Please reload the page and try again.'):
+            question_services.update_question(
+                self.editor_id, self.question_id_2,
+                changelist, 'change language_code', 2)
+
+        question_model = question_models.QuestionModel.get(self.question_id_2)
+        question_model.version = 100
+        with self.assertRaisesRegex(
+            Exception,
+            'Unexpected error: trying to update version 1 of question '
+            'from version 100. Please reload the page and try again.'):
+            question_services.update_question(
+                self.editor_id, self.question_id_2,
+                changelist, 'change language_code', 1)
+
     def test_replace_skill_id_for_all_questions(self) -> None:
         question_id_2 = question_services.get_new_question_id()
         content_id_generator = translation_domain.ContentIdGenerator()