Repository: oppia/oppia

Issue:
[Contributor Dashboard] Allow reviewers to edit translation and question suggestions 
This will enable reviewers to correct any small mistakes and accept a question suggestion, rather than having to reject the suggestion entirely. Ideally any changes made to the original suggestion would also be surfaced to the suggestion submitter when the view the accepted suggestion.


Discussion:
Consider adding an annotation along the lines of "With edits" in the completed review.
> Consider adding an annotation along the lines of "With edits" in the completed review.

To handle this programatically, a new flag will be implemented to the suggestion storage model. The implementation approch for this is described in [this](https://docs.google.com/document/d/1a0FDeFo_1ZHsRkYOIDsshuJntAzsfiNfVSAUIIByBkU/edit) doc. This implementation is further required for https://github.com/oppia/oppia/pull/12540
cc/ @sagangwee @seanlip 

Resolving patch:
diff --git a/core/controllers/suggestion.py b/core/controllers/suggestion.py
--- a/core/controllers/suggestion.py
+++ b/core/controllers/suggestion.py
@@ -36,59 +36,6 @@
 import utils
 
 
-def _get_target_id_to_exploration_opportunity_dict(suggestions):
-    """Returns a dict of target_id to exploration opportunity summary dict.
-
-    Args:
-        suggestions: list(BaseSuggestion). A list of suggestions to retrieve
-            opportunity dicts.
-
-    Returns:
-        dict. Dict mapping target_id to corresponding exploration opportunity
-        summary dict.
-    """
-    target_ids = set(s.target_id for s in suggestions)
-    opportunity_id_to_opportunity_dict = {
-        opp_id: (opp.to_dict() if opp is not None else None)
-        for opp_id, opp in (
-            opportunity_services.get_exploration_opportunity_summaries_by_ids(
-                list(target_ids)).items())
-    }
-    return opportunity_id_to_opportunity_dict
-
-
-def _get_target_id_to_skill_opportunity_dict(suggestions):
-    """Returns a dict of target_id to skill opportunity summary dict.
-
-    Args:
-        suggestions: list(BaseSuggestion). A list of suggestions to retrieve
-            opportunity dicts.
-
-    Returns:
-        dict. Dict mapping target_id to corresponding skill opportunity dict.
-    """
-    target_ids = set(s.target_id for s in suggestions)
-    opportunity_id_to_opportunity_dict = {
-        opp_id: (opp.to_dict() if opp is not None else None)
-        for opp_id, opp in opportunity_services.get_skill_opportunities_by_ids(
-            list(target_ids)).items()
-    }
-    opportunity_id_to_skill = {
-        skill.id: skill
-        for skill in skill_fetchers.get_multi_skills([
-            opp['id']
-            for opp in opportunity_id_to_opportunity_dict.values()
-            if opp is not None])
-    }
-
-    for opp_id, skill in opportunity_id_to_skill.items():
-        if skill is not None:
-            opportunity_id_to_opportunity_dict[opp_id]['skill_rubrics'] = [
-                rubric.to_dict() for rubric in skill.rubrics]
-
-    return opportunity_id_to_opportunity_dict
-
-
 class SuggestionHandler(base.BaseHandler):
     """"Handles operations relating to suggestions."""
 
@@ -125,43 +72,10 @@ def post(self):
             self.render_json(self.values)
             return
 
-        # TODO(#10513) : Find a way to save the images before the suggestion is
-        # created.
-        suggestion_image_context = suggestion.image_context
-
-        new_image_filenames = (
+        _upload_suggestion_images(
+            self.request,
+            suggestion,
             suggestion.get_new_image_filenames_added_in_suggestion())
-        for filename in new_image_filenames:
-            image = self.request.get(filename)
-            if not image:
-                logging.exception(
-                    'Image not provided for file with name %s when the '
-                    ' suggestion with target id %s was created.' % (
-                        filename, suggestion.target_id))
-                raise self.InvalidInputException(
-                    'No image data provided for file with name %s.'
-                    % (filename))
-            try:
-                file_format = (
-                    image_validation_services.validate_image_and_filename(
-                        image, filename))
-            except utils.ValidationError as e:
-                raise self.InvalidInputException('%s' % (e))
-            image_is_compressible = (
-                file_format in feconf.COMPRESSIBLE_IMAGE_FORMATS)
-            fs_services.save_original_and_compressed_versions_of_image(
-                filename, suggestion_image_context, suggestion.target_id,
-                image, 'image', image_is_compressible)
-
-        target_entity_html_list = suggestion.get_target_entity_html_strings()
-        target_image_filenames = (
-            html_cleaner.get_image_filenames_from_html_strings(
-                target_entity_html_list))
-
-        fs_services.copy_images(
-            suggestion.target_type, suggestion.target_id,
-            suggestion_image_context, suggestion.target_id,
-            target_image_filenames)
 
         self.render_json(self.values)
 
@@ -442,7 +356,7 @@ class UpdateQuestionSuggestionHandler(base.BaseHandler):
     """Handles update operations relating to question suggestions."""
 
     @acl_decorators.can_update_suggestion
-    def put(self, suggestion_id):
+    def post(self, suggestion_id):
         """Handles PUT requests.
 
         Raises:
@@ -478,12 +392,120 @@ def put(self, suggestion_id):
 
         question_state_data_obj = state_domain.State.from_dict(
             self.payload.get('question_state_data'))
-
         question_state_data_obj.validate(None, False)
 
-        suggestion_services.update_question_suggestion(
+        updated_suggestion = suggestion_services.update_question_suggestion(
             suggestion_id,
             self.payload.get('skill_difficulty'),
             self.payload.get('question_state_data'))
 
+        new_image_filenames = (
+            utils.compute_list_difference(
+                updated_suggestion
+                    .get_new_image_filenames_added_in_suggestion(),
+                suggestion.get_new_image_filenames_added_in_suggestion()
+            )
+        )
+        _upload_suggestion_images(
+            self.request, updated_suggestion, new_image_filenames)
+
         self.render_json(self.values)
+
+
+def _get_target_id_to_exploration_opportunity_dict(suggestions):
+    """Returns a dict of target_id to exploration opportunity summary dict.
+
+    Args:
+        suggestions: list(BaseSuggestion). A list of suggestions to retrieve
+            opportunity dicts.
+
+    Returns:
+        dict. Dict mapping target_id to corresponding exploration opportunity
+        summary dict.
+    """
+    target_ids = set(s.target_id for s in suggestions)
+    opportunity_id_to_opportunity_dict = {
+        opp_id: (opp.to_dict() if opp is not None else None)
+        for opp_id, opp in (
+            opportunity_services.get_exploration_opportunity_summaries_by_ids(
+                list(target_ids)).items())
+    }
+    return opportunity_id_to_opportunity_dict
+
+
+def _get_target_id_to_skill_opportunity_dict(suggestions):
+    """Returns a dict of target_id to skill opportunity summary dict.
+
+    Args:
+        suggestions: list(BaseSuggestion). A list of suggestions to retrieve
+            opportunity dicts.
+
+    Returns:
+        dict. Dict mapping target_id to corresponding skill opportunity dict.
+    """
+    target_ids = set(s.target_id for s in suggestions)
+    opportunity_id_to_opportunity_dict = {
+        opp_id: (opp.to_dict() if opp is not None else None)
+        for opp_id, opp in opportunity_services.get_skill_opportunities_by_ids(
+            list(target_ids)).items()
+    }
+    opportunity_id_to_skill = {
+        skill.id: skill
+        for skill in skill_fetchers.get_multi_skills([
+            opp['id']
+            for opp in opportunity_id_to_opportunity_dict.values()
+            if opp is not None])
+    }
+
+    for opp_id, skill in opportunity_id_to_skill.items():
+        if skill is not None:
+            opportunity_id_to_opportunity_dict[opp_id]['skill_rubrics'] = [
+                rubric.to_dict() for rubric in skill.rubrics]
+
+    return opportunity_id_to_opportunity_dict
+
+
+def _upload_suggestion_images(request, suggestion, filenames):
+    """Saves a suggestion's images to storage.
+
+    Args:
+        request: webapp2.Request. Request object containing a mapping of image
+            filename to image blob.
+        suggestion: BaseSuggestion. The suggestion for which images are being
+            uploaded.
+        filenames: list(str). The image filenames.
+    """
+    suggestion_image_context = suggestion.image_context
+    # TODO(#10513) : Find a way to save the images before the suggestion is
+    # created.
+    for filename in filenames:
+        image = request.get(filename)
+        if not image:
+            logging.exception(
+                'Image not provided for file with name %s when the '
+                ' suggestion with target id %s was created.' % (
+                    filename, suggestion.target_id))
+            raise base.BaseHandler.InvalidInputException(
+                'No image data provided for file with name %s.'
+                % (filename))
+        try:
+            file_format = (
+                image_validation_services.validate_image_and_filename(
+                    image, filename))
+        except utils.ValidationError as e:
+            raise base.BaseHandler.InvalidInputException('%s' % (e))
+        image_is_compressible = (
+            file_format in feconf.COMPRESSIBLE_IMAGE_FORMATS)
+        fs_services.save_original_and_compressed_versions_of_image(
+            filename, suggestion_image_context, suggestion.target_id,
+            image, 'image', image_is_compressible)
+
+    target_entity_html_list = suggestion.get_target_entity_html_strings()
+    target_image_filenames = (
+        html_cleaner.get_image_filenames_from_html_strings(
+            target_entity_html_list))
+
+    fs_services.copy_images(
+        suggestion.target_type, suggestion.target_id,
+        suggestion_image_context, suggestion.target_id,
+        target_image_filenames)
diff --git a/core/domain/suggestion_services.py b/core/domain/suggestion_services.py
--- a/core/domain/suggestion_services.py
+++ b/core/domain/suggestion_services.py
@@ -266,7 +266,6 @@ def _update_suggestion(suggestion):
     Args:
         suggestion: Suggestion. The suggestion to be updated.
     """
-
     _update_suggestions([suggestion])
 
 
@@ -1362,6 +1361,10 @@ def update_question_suggestion(
         suggestion_id: str. The id of the suggestion to be updated.
         skill_difficulty: double. The difficulty level of the question.
         question_state_data: obj. Details of the question.
+
+    Returns:
+        Suggestion|None. The corresponding suggestion, or None if no suggestion
+        is found.
     """
     suggestion = get_suggestion_by_id(suggestion_id)
     new_change_obj = question_domain.QuestionSuggestionChange(
@@ -1388,3 +1391,5 @@ def update_question_suggestion(
     suggestion.change = new_change_obj
 
     _update_suggestion(suggestion)
+
+    return suggestion


Test patch:
diff --git a/core/controllers/suggestion_test.py b/core/controllers/suggestion_test.py
--- a/core/controllers/suggestion_test.py
+++ b/core/controllers/suggestion_test.py
@@ -957,19 +957,29 @@ def test_update_suggestion_updates_question_suggestion_content(self):
         question_state_data = suggestion.change.question_dict[
             'question_state_data']
         question_state_data['content'][
-            'html'] = '<p>Updated question</p>'
+            'html'] = (
+                '<p>Updated question</p>'
+                '<oppia-noninteractive-image filepath-with-value='
+                '"&quot;img.png&quot;" caption-with-value="&quot;&quot;" '
+                'alt-with-value="&quot;Image&quot;">'
+                '</oppia-noninteractive-image>')
         question_state_data['interaction'][
             'solution'] = new_solution_dict
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
+        with python_utils.open_file(
+            os.path.join(feconf.TESTS_DATA_DIR, 'img.png'),
+            'rb', encoding=None) as f:
+            raw_image = f.read()
 
-        self.put_json('%s/%s' % (
+        self.post_json('%s/%s' % (
             feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
             suggestion.suggestion_id), {
                 'question_state_data': question_state_data,
                 'skill_difficulty': 0.6
-            }, csrf_token=csrf_token)
+            }, csrf_token=csrf_token, upload_files=(
+                ('img.png', 'img.png', raw_image),))
 
         updated_suggestion = suggestion_services.get_suggestion_by_id(
             suggestion.suggestion_id)
@@ -979,14 +989,84 @@ def test_update_suggestion_updates_question_suggestion_content(self):
         self.assertEqual(
             new_question_state_data['content'][
                 'html'],
-            '<p>Updated question</p>')
+            (
+                '<p>Updated question</p>'
+                '<oppia-noninteractive-image filepath-with-value='
+                '"&quot;img.png&quot;" caption-with-value="&quot;&quot;" '
+                'alt-with-value="&quot;Image&quot;">'
+                '</oppia-noninteractive-image>'))
         self.assertEqual(
             new_question_state_data['interaction'][
                 'solution'],
             new_solution_dict)
         self.logout()
 
-    def test_cannot_update_question_with_invalid_skill_difficulty(self):
+    def test_update_question_suggestion_with_large_image_throws_error(self):
+        skill_id = skill_services.get_new_skill_id()
+        self.save_new_skill(
+            skill_id, self.author_id, description='description')
+        suggestion_change = {
+            'cmd': (
+                question_domain
+                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'question_dict': {
+                'question_state_data': self._create_valid_question_data(
+                    'default_state').to_dict(),
+                'language_code': 'en',
+                'question_state_data_schema_version': (
+                    feconf.CURRENT_STATE_SCHEMA_VERSION),
+                'linked_skill_ids': ['skill_1'],
+                'inapplicable_skill_misconception_ids': ['skillid12345-1']
+            },
+            'skill_id': skill_id,
+            'skill_difficulty': 0.3
+        }
+        new_solution_dict = {
+            'answer_is_exclusive': False,
+            'correct_answer': 'Solution',
+            'explanation': {
+                'content_id': 'solution',
+                'html': '<p>This is the updated solution.</p>',
+            },
+        }
+        suggestion = suggestion_services.create_suggestion(
+            feconf.SUGGESTION_TYPE_ADD_QUESTION,
+            feconf.ENTITY_TYPE_SKILL, skill_id, 1,
+            self.author_id, suggestion_change, 'test description')
+
+        question_state_data = self._create_valid_question_data(
+            'default_state').to_dict()
+        html_with_file = (
+            '<oppia-noninteractive-math math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)(x - a'
+            '_3)...(x - a_n-1)(x - a_n)&amp;quot;, &amp;quot;svg_filenam'
+            'e&amp;quot;: &amp;quot;file.svg&amp;quot;}"></oppia-noninte'
+            'ractive-math>'
+        )
+        question_state_data['content']['html'] = html_with_file
+        question_state_data['interaction'][
+            'solution'] = new_solution_dict
+
+        self.login(self.CURRICULUM_ADMIN_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        large_image = '<svg><path d="%s" /></svg>' % (
+            'M150 0 L75 200 L225 200 Z ' * 4000)
+
+        response_dict = self.post_json('%s/%s' % (
+            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
+            suggestion.suggestion_id), {
+                'question_state_data': question_state_data,
+                'skill_difficulty': 0.6
+            }, csrf_token=csrf_token, expected_status_int=400, upload_files=(
+                ('file.svg', 'file.svg', large_image),),)
+
+        self.assertIn(
+            'Image exceeds file size limit of 100 KB.',
+            response_dict['error'])
+        self.logout()
+
+    def test_update_question_suggestion_with_missing_image_data_throws_error(
+            self):
         skill_id = skill_services.get_new_skill_id()
         self.save_new_skill(
             skill_id, self.author_id, description='description')
@@ -1006,6 +1086,21 @@ def test_cannot_update_question_with_invalid_skill_difficulty(self):
             'skill_id': skill_id,
             'skill_difficulty': 0.3
         }
+        suggestion = suggestion_services.create_suggestion(
+            feconf.SUGGESTION_TYPE_ADD_QUESTION,
+            feconf.ENTITY_TYPE_SKILL, skill_id, 1,
+            self.author_id, suggestion_change, 'test description')
+
+        question_state_data = self._create_valid_question_data(
+            'default_state').to_dict()
+        html_with_file = (
+            '<oppia-noninteractive-math math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)(x - a'
+            '_3)...(x - a_n-1)(x - a_n)&amp;quot;, &amp;quot;svg_filenam'
+            'e&amp;quot;: &amp;quot;file.svg&amp;quot;}"></oppia-noninte'
+            'ractive-math>'
+        )
+        question_state_data['content']['html'] = html_with_file
         new_solution_dict = {
             'answer_is_exclusive': False,
             'correct_answer': 'Solution',
@@ -1014,6 +1109,44 @@ def test_cannot_update_question_with_invalid_skill_difficulty(self):
                 'html': '<p>This is the updated solution.</p>',
             },
         }
+        question_state_data['interaction'][
+            'solution'] = new_solution_dict
+
+        self.login(self.CURRICULUM_ADMIN_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        response_dict = self.post_json('%s/%s' % (
+            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
+            suggestion.suggestion_id), {
+                'question_state_data': question_state_data,
+                'skill_difficulty': 0.6
+            }, csrf_token=csrf_token, expected_status_int=400)
+
+        self.assertIn(
+            'No image data provided for file with name file.svg.',
+            response_dict['error'])
+        self.logout()
+
+    def test_cannot_update_question_with_invalid_skill_difficulty(self):
+        skill_id = skill_services.get_new_skill_id()
+        self.save_new_skill(
+            skill_id, self.author_id, description='description')
+        suggestion_change = {
+            'cmd': (
+                question_domain
+                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'question_dict': {
+                'question_state_data': self._create_valid_question_data(
+                    'default_state').to_dict(),
+                'language_code': 'en',
+                'question_state_data_schema_version': (
+                    feconf.CURRENT_STATE_SCHEMA_VERSION),
+                'linked_skill_ids': ['skill_1'],
+                'inapplicable_skill_misconception_ids': ['skillid12345-1']
+            },
+            'skill_id': skill_id,
+            'skill_difficulty': 0.3
+        }
         suggestion = suggestion_services.create_suggestion(
             feconf.SUGGESTION_TYPE_ADD_QUESTION,
             feconf.ENTITY_TYPE_SKILL, skill_id, 1,
@@ -1023,13 +1156,21 @@ def test_cannot_update_question_with_invalid_skill_difficulty(self):
             'question_state_data']
         question_state_data['content'][
             'html'] = '<p>Updated question</p>'
+        new_solution_dict = {
+            'answer_is_exclusive': False,
+            'correct_answer': 'Solution',
+            'explanation': {
+                'content_id': 'solution',
+                'html': '<p>This is the updated solution.</p>',
+            },
+        }
         question_state_data['interaction'][
             'solution'] = new_solution_dict
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.put_json('%s/%s' % (
+        response = self.post_json('%s/%s' % (
             feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
             suggestion.suggestion_id), {
                 'question_state_data': question_state_data,
@@ -1084,7 +1225,7 @@ def test_cannot_update_question_without_state_data(self):
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.put_json('%s/%s' % (
+        response = self.post_json('%s/%s' % (
             feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
             suggestion.suggestion_id), {
                 'skill_difficulty': 0.6
@@ -1138,7 +1279,7 @@ def test_cannot_update_question_without_skill_difficulty(self):
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.put_json('%s/%s' % (
+        response = self.post_json('%s/%s' % (
             feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
             suggestion.suggestion_id), {
                 'question_state_data': question_state_data
@@ -1195,7 +1336,7 @@ def test_cannot_update_already_handled_question(self):
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.put_json('%s/%s' % (
+        response = self.post_json('%s/%s' % (
             feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
             suggestion.suggestion_id), {
                 'question_state_data': question_state_data,
@@ -1238,7 +1379,7 @@ def test_cannot_update_question_when_provided_state_data_is_invalid(self):
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        self.put_json('%s/%s' % (
+        self.post_json('%s/%s' % (
             feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
             suggestion.suggestion_id), {
                 'question_state_data': invalid_question_state_data,