Repository: oppia/oppia

Issue:
[Test server] Unable to accept or reject suggestion through exploration editor page.
**Steps to reproduce:**
1. Create and publish an exploration.
2. Create another account and suggest some changes in the exploration.
3. Go to the editor page of exploration and try accepting the suggestion.
4. Unable to accept the suggestion.

**Screenshot:**
![image](https://user-images.githubusercontent.com/16653571/59193619-e934ea00-8ba3-11e9-9601-e66543e2ffab.png)

**Additional context:**

Suggestion modal also shows "state doesn't exist" (see image below), although the exploration version isn't changed:
![image](https://user-images.githubusercontent.com/16653571/59193981-16ce6300-8ba5-11e9-9b15-ba0c7e48ddfa.png)



Discussion:
Hmmm, not sure why this happens. Will take a look.
@nithusha21, Just as an FYI, this issue still persists in the test server. 
Hmmm, can you confirm 1 thing... What is the category of the exploration you created? Is it among the standard list of exploration categories?
Just a quick update. I tried this out, and it worked fine (rejection). Could you please retry it? I think if you pick from the standard list it works fine. I didn't know that you could pick a new category which isn't in the list. 
Okay, I went through the [settings page of the exploration](https://oppiatestserver.appspot.com/create/0nGKU3BdS_Xb#/settings) and realized the Category of the exploration is `Demo`, I don't know how it is possible, but I think this was causing the issue. I tried this in other exploration and it's working fine!

Do we have a validation check for the category of exploration to make sure it contains a defined category?
@nithusha21, I think we allow adding any text as the category for the exploration. is it expected behavior?
I don't know about this. In the suggestion model however, for score category we have a validation check to make sure that the second part of the value, which is the exploration category lies in the known list of score categories.
After discussion with Sean, this is the plan of action. So the score category in suggestions has a very strong validation, which is not expected. We expect it to match the validation for the exploration category. I am going to mark this as non-blocking as this has been around for more than a year, and not been reported. Will move it to the bug-fixing team, and will file a PR ASAP. But I think this shouldn't block the release!
@nithusha21 How is this issue going?
@nithusha21 are you still working on this?

Resolving patch:
diff --git a/core/domain/prod_validation_jobs_one_off.py b/core/domain/prod_validation_jobs_one_off.py
--- a/core/domain/prod_validation_jobs_one_off.py
+++ b/core/domain/prod_validation_jobs_one_off.py
@@ -106,16 +106,9 @@
     suggestion_models.TARGET_TYPE_TOPIC: (
         topic_models.TopicModel)
 }
-VALID_SCORE_CATEGORIES_FOR_TYPE_CONTENT = [
-    '%s\\.%s' % (
-        suggestion_models.SCORE_TYPE_CONTENT, category) for category in (
-            constants.ALL_CATEGORIES)]
 VALID_SCORE_CATEGORIES_FOR_TYPE_QUESTION = [
     '%s\\.[A-Za-z0-9-_]{1,%s}' % (
         suggestion_models.SCORE_TYPE_QUESTION, base_models.ID_LENGTH)]
-ALLOWED_SCORE_CATEGORIES = (
-    VALID_SCORE_CATEGORIES_FOR_TYPE_CONTENT +
-    VALID_SCORE_CATEGORIES_FOR_TYPE_QUESTION)
 
 
 class BaseModelValidator(python_utils.OBJECT):
@@ -3183,12 +3176,55 @@ def _validate_final_reveiwer_id(cls, item):
                 'Entity id %s: Final reviewer id %s is not empty but '
                 'suggestion is in review' % (item.id, item.final_reviewer_id))
 
+    @classmethod
+    def _validate_score_category(cls, item):
+        """Validate that the score_category subtype for suggestions matches the
+        exploration category.
+
+        Args:
+            item: ndb.Model. GeneralSuggestionModel to validate.
+        """
+        if item.target_type not in TARGET_TYPE_TO_TARGET_MODEL:
+            return
+        score_category_type = (
+            item.score_category.split(
+                suggestion_models.SCORE_CATEGORY_DELIMITER)[0])
+        score_category_sub_type = (
+            item.score_category.split(
+                suggestion_models.SCORE_CATEGORY_DELIMITER)[1])
+        if item.target_type == suggestion_models.TARGET_TYPE_EXPLORATION:
+            target_model_class_model_id_model_tuples = (
+                cls.external_instance_details['%s_ids' % item.target_type])
+
+            for (_, _, target_model) in (
+                    target_model_class_model_id_model_tuples):
+                # The case for missing target external model is ignored here
+                # since errors for missing target external model are already
+                # checked and stored in _validate_external_id_relationships
+                # function.
+                if target_model is None or target_model.deleted:
+                    continue
+                if target_model.category != score_category_sub_type:
+                    cls.errors['score category subtype check'].append(
+                        'Entity id %s: score category sub %s does not match'
+                        ' target exploration category %s' % (
+                            item.id, score_category_sub_type,
+                            target_model.category))
+        if score_category_type == suggestion_models.SCORE_TYPE_QUESTION:
+            score_category_regex = (
+                '^(%s)$' % ('|').join(VALID_SCORE_CATEGORIES_FOR_TYPE_QUESTION))
+            if not re.compile(score_category_regex).match(item.score_category):
+                cls.errors['score category check'].append(
+                    'Entity id %s: Score category %s is invalid' % (
+                        item.id, item.score_category))
+
     @classmethod
     def _get_custom_validation_functions(cls):
         return [
             cls._validate_target_type,
             cls._validate_target_version_at_submission,
-            cls._validate_final_reveiwer_id]
+            cls._validate_final_reveiwer_id,
+            cls._validate_score_category]
 
 
 class GeneralVoiceoverApplicationModelValidator(BaseModelValidator):
@@ -4818,19 +4854,6 @@ def _get_external_id_relationships(cls, item):
                 user_models.UserSettingsModel, [item.user_id])
         }
 
-    @classmethod
-    def _validate_score_category(cls, item):
-        """Validates that score category belongs to allowed score categories.
-
-        Args:
-            item: ndb.Model. UserContributionScoringModel to validate.
-        """
-        score_category_regex = '^(%s)$' % ('|').join(ALLOWED_SCORE_CATEGORIES)
-        if not re.compile(score_category_regex).match(item.score_category):
-            cls.errors['score category check'].append(
-                'Entity id %s: Score category %s is invalid' % (
-                    item.id, item.score_category))
-
     @classmethod
     def _validate_score(cls, item):
         """Validates that score is non-negative.
@@ -4845,9 +4868,7 @@ def _validate_score(cls, item):
 
     @classmethod
     def _get_custom_validation_functions(cls):
-        return [
-            cls._validate_score_category,
-            cls._validate_score]
+        return [cls._validate_score]
 
 
 class UserCommunityRightsModelValidator(BaseUserModelValidator):
diff --git a/core/domain/suggestion_jobs_one_off.py b/core/domain/suggestion_jobs_one_off.py
--- a/core/domain/suggestion_jobs_one_off.py
+++ b/core/domain/suggestion_jobs_one_off.py
@@ -78,23 +78,30 @@ def map(item):
                 SuggestionMathMigrationOneOffJob._ERROR_KEY_BEFORE_MIGRATION,
                 'Suggestion %s failed validation: %s' % (item.id, e))
             return
-        suggestion.convert_html_in_suggestion_change(
-            html_validation_service.add_math_content_to_math_rte_components)
-
-        try:
-            suggestion.validate()
-        except Exception as e:
-            logging.error(
-                'Suggestion %s failed validation after migration: %s' % (
-                    item.id, e))
-            yield (
-                SuggestionMathMigrationOneOffJob._ERROR_KEY_AFTER_MIGRATION,
-                'Suggestion %s failed validation: %s' % (
-                    item.id, e))
-            return
-        item.change_cmd = suggestion.change.to_dict()
-        item.put(update_last_updated_time=False)
-        yield ('suggestion_migrated', 1)
+        html_string_list = suggestion.get_all_html_content_strings()
+        html_string = ''.join(html_string_list)
+        error_list = (
+            html_validation_service.
+            validate_math_tags_in_html_with_attribute_math_content(html_string))
+        # Migrate the suggestion only if the suggestions have math-tags with
+        # old schema.
+        if len(error_list) > 0:
+            suggestion.convert_html_in_suggestion_change(
+                html_validation_service.add_math_content_to_math_rte_components)
+            try:
+                suggestion.validate()
+            except Exception as e:
+                logging.error(
+                    'Suggestion %s failed validation after migration: %s' % (
+                        item.id, e))
+                yield (
+                    SuggestionMathMigrationOneOffJob._ERROR_KEY_AFTER_MIGRATION,
+                    'Suggestion %s failed validation: %s' % (
+                        item.id, e))
+                return
+            item.change_cmd = suggestion.change.to_dict()
+            item.put(update_last_updated_time=False)
+            yield ('suggestion_migrated', 1)
 
     @staticmethod
     def reduce(key, values):
@@ -103,7 +110,9 @@ def reduce(key, values):
                     SuggestionMathMigrationOneOffJob._ERROR_KEY_AFTER_MIGRATION,
                     (SuggestionMathMigrationOneOffJob.
                      _ERROR_KEY_BEFORE_MIGRATION)]):
+            no_of_suggestions_migrated = (
+                sum(ast.literal_eval(v) for v in values))
             yield (key, ['%d suggestions successfully migrated.' % (
-                sum(ast.literal_eval(v) for v in values))])
+                no_of_suggestions_migrated)])
         else:
             yield (key, values)
diff --git a/core/domain/suggestion_registry.py b/core/domain/suggestion_registry.py
--- a/core/domain/suggestion_registry.py
+++ b/core/domain/suggestion_registry.py
@@ -297,11 +297,6 @@ def validate(self):
                     suggestion_models.SCORE_TYPE_CONTENT,
                     self.get_score_type()))
 
-        if self.get_score_sub_type() not in constants.ALL_CATEGORIES:
-            raise utils.ValidationError(
-                'Expected the second part of score_category to be a valid'
-                ' category, received %s' % self.get_score_sub_type())
-
         if self.change.cmd != exp_domain.CMD_EDIT_STATE_PROPERTY:
             raise utils.ValidationError(
                 'Expected cmd to be %s, received %s' % (
@@ -457,7 +452,10 @@ def validate(self):
             raise utils.ValidationError(
                 'Expected change to be an ExplorationChange, received %s'
                 % type(self.change))
-
+        # The score sub_type needs to match the validation for exploration
+        # category, i.e the second part of the score_category should match
+        # the target exploration's category and we have a prod validation
+        # for the same.
         if self.get_score_type() != suggestion_models.SCORE_TYPE_TRANSLATION:
             raise utils.ValidationError(
                 'Expected the first part of score_category to be %s '
@@ -465,11 +463,6 @@ def validate(self):
                     suggestion_models.SCORE_TYPE_TRANSLATION,
                     self.get_score_type()))
 
-        if self.get_score_sub_type() not in constants.ALL_CATEGORIES:
-            raise utils.ValidationError(
-                'Expected the second part of score_category to be a valid'
-                ' category, received %s' % self.get_score_sub_type())
-
         if self.change.cmd != exp_domain.CMD_ADD_TRANSLATION:
             raise utils.ValidationError(
                 'Expected cmd to be %s, received %s' % (


Test patch:
diff --git a/core/domain/prod_validation_jobs_one_off_test.py b/core/domain/prod_validation_jobs_one_off_test.py
--- a/core/domain/prod_validation_jobs_one_off_test.py
+++ b/core/domain/prod_validation_jobs_one_off_test.py
@@ -9060,7 +9060,6 @@ def test_model_with_invalid_story_related_property(self):
 
 
 class GeneralSuggestionModelValidatorTests(test_utils.GenericTestBase):
-
     def setUp(self):
         super(GeneralSuggestionModelValidatorTests, self).setUp()
 
@@ -9222,7 +9221,7 @@ def test_invalid_non_empty_final_reviewer_id(self):
         run_job_and_check_output(self, expected_output, sort=True)
 
     def test_model_with_invalid_schema(self):
-        self.model_instance.score_category = 'invalid.invalid'
+        self.model_instance.score_category = 'invalid.Art'
         self.model_instance.put()
         expected_output = [
             (
@@ -9245,6 +9244,97 @@ def test_model_with_invalid_target_type(self):
             prod_validation_jobs_one_off, 'TARGET_TYPE_TO_TARGET_MODEL', {}):
             run_job_and_check_output(self, expected_output, sort=True)
 
+    def test_validate_score_category_for_content_suggestion(self):
+        self.model_instance.score_category = 'content.invalid'
+        self.model_instance.put()
+        expected_output = [(
+            u'[u\'failed validation check for score category subtype check of '
+            'GeneralSuggestionModel\', [u\'Entity id %s: score category sub in'
+            'valid does not match target exploration category Art\']]') % (
+                self.model_instance.id)]
+        run_job_and_check_output(self, expected_output)
+
+    def test_validate_score_category_for_transalation_suggestion(self):
+        change = {
+            'cmd': exp_domain.CMD_ADD_TRANSLATION,
+            'state_name': 'Introduction',
+            'content_id': 'content',
+            'language_code': 'hi',
+            'content_html': '<p>This is a content.</p>',
+            'translation_html': '<p>This is translated html.</p>'
+        }
+        score_category = (
+            suggestion_models.SCORE_TYPE_TRANSLATION +
+            suggestion_models.SCORE_CATEGORY_DELIMITER + 'invalid')
+
+        thread_id = feedback_services.create_thread(
+            'exploration', '0', self.owner_id, 'description',
+            'suggestion', has_suggestion=True)
+
+        suggestion_models.GeneralSuggestionModel.create(
+            suggestion_models.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            suggestion_models.TARGET_TYPE_EXPLORATION, '0',
+            1, suggestion_models.STATUS_ACCEPTED, self.owner_id,
+            self.admin_id, change, score_category, thread_id)
+        model_instance = (
+            suggestion_models.GeneralSuggestionModel.get_by_id(thread_id))
+        expected_output = [((
+            u'[u\'failed validation check for score category subtype check of '
+            'GeneralSuggestionModel\', [u\'Entity id %s: score category sub in'
+            'valid does not match target exploration category Art\']]') % (
+                model_instance.id)),
+                           u'[u\'fully-validated GeneralSuggestionModel\', 1]']
+        run_job_and_check_output(self, expected_output, sort=True)
+
+    def test_validate_score_category_for_question_suggestion(self):
+        rubrics = [
+            skill_domain.Rubric(
+                constants.SKILL_DIFFICULTIES[0], ['Explanation 1']),
+            skill_domain.Rubric(
+                constants.SKILL_DIFFICULTIES[1], ['Explanation 2']),
+            skill_domain.Rubric(
+                constants.SKILL_DIFFICULTIES[2], ['Explanation 3'])]
+        skill = skill_domain.Skill.create_default_skill(
+            '0', 'skill_description', rubrics)
+        skill_services.save_new_skill(self.owner_id, skill)
+
+        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': ['0']
+            },
+            'skill_id': '0',
+            'skill_difficulty': 0.3,
+        }
+
+        score_category = (
+            suggestion_models.SCORE_TYPE_QUESTION +
+            suggestion_models.SCORE_CATEGORY_DELIMITER + 'invalid_sub_category')
+
+        thread_id = feedback_services.create_thread(
+            'skill', '0', self.owner_id, 'description',
+            'suggestion', has_suggestion=True)
+
+        suggestion_models.GeneralSuggestionModel.create(
+            suggestion_models.SUGGESTION_TYPE_ADD_QUESTION,
+            suggestion_models.TARGET_TYPE_SKILL, '0',
+            1, suggestion_models.STATUS_ACCEPTED, self.owner_id,
+            self.admin_id, change, score_category, thread_id)
+        model_instance = (
+            suggestion_models.GeneralSuggestionModel.get_by_id(thread_id))
+        expected_output = [(
+            u'[u\'failed validation check for score category check of '
+            'GeneralSuggestionModel\', [u\'Entity id %s: Score category'
+            ' question.invalid_sub_category is invalid\']]') % (
+                model_instance.id),
+                           u'[u\'fully-validated GeneralSuggestionModel\', 1]']
+        run_job_and_check_output(self, expected_output, sort=True)
+
 
 class GeneralVoiceoverApplicationModelValidatorTests(
         test_utils.GenericTestBase):
@@ -14559,18 +14649,6 @@ def test_missing_user_settings_model_failure(self):
                     self.model_instance.id, self.user_id, self.user_id)]
         run_job_and_check_output(self, expected_output)
 
-    def test_invalid_score_category(self):
-        suggestion_services.create_new_user_contribution_scoring_model(
-            self.user_id, 'invalid', 10)
-        expected_output = [
-            (
-                u'[u\'failed validation check for score category check '
-                'of UserContributionScoringModel\', [u\'Entity id invalid.%s: '
-                'Score category invalid is invalid\']]'
-            ) % self.user_id,
-            u'[u\'fully-validated UserContributionScoringModel\', 1]']
-        run_job_and_check_output(self, expected_output, sort=True)
-
     def test_invalid_score(self):
         self.model_instance.score = -1
         self.model_instance.put()
diff --git a/core/domain/suggestion_jobs_one_off_test.py b/core/domain/suggestion_jobs_one_off_test.py
--- a/core/domain/suggestion_jobs_one_off_test.py
+++ b/core/domain/suggestion_jobs_one_off_test.py
@@ -891,6 +891,105 @@ def test_migrate_in_suggestion_edit_content_with_math_rte_correctly(self):
             expected_change_dict,
             observed_translation_suggestion.to_dict()['change'])
 
+    def test_skip_migration_for_suggestion_with_new_math_schema(self):
+        """Tests that a suggestion already having the new math_schema is not
+        migrated.
+        """
+        expected_html_content = (
+            '<p>Value</p><oppia-noninteractive-math math_content-with-value='
+            '"{&amp;quot;raw_latex&amp;quot;: &amp;quot;+,-,-,+&amp;quot;, &'
+            'amp;quot;svg_filename&amp;quot;: &amp;quot;&amp;quot;}"></oppia'
+            '-noninteractive-math>')
+
+        state_dict = {
+            'classifier_model_id': None,
+            'content': {
+                'content_id': 'content',
+                'html': expected_html_content
+            },
+            'interaction': {
+                'answer_groups': [],
+                'confirmed_unclassified_answers': [],
+                'customization_args': {},
+                'default_outcome': {
+                    'dest': 'Introduction',
+                    'feedback': {
+                        'content_id': 'default_outcome',
+                        'html': expected_html_content
+                    },
+                    'labelled_as_correct': False,
+                    'param_changes': [],
+                    'refresher_exploration_id': None,
+                    'missing_prerequisite_skill_id': None
+                },
+                'hints': [],
+                'id': None,
+                'solution': None,
+            },
+            'param_changes': [],
+            'recorded_voiceovers': {
+                'voiceovers_mapping': {
+                    'content': {},
+                    'default_outcome': {}
+                }
+            },
+            'solicit_answer_details': False,
+            'written_translations': {
+                'translations_mapping': {
+                    'content': {},
+                    'default_outcome': {}
+                }
+            }
+        }
+        states = {
+            'Introduction': state_dict
+        }
+        exploration = (
+            exp_domain.Exploration(
+                'exp1', feconf.DEFAULT_EXPLORATION_TITLE, 'Algebra',
+                feconf.DEFAULT_EXPLORATION_OBJECTIVE,
+                constants.DEFAULT_LANGUAGE_CODE, [], '', '',
+                feconf.CURRENT_STATE_SCHEMA_VERSION,
+                feconf.DEFAULT_INIT_STATE_NAME, states, {}, [], 0, False,
+                False))
+        exp_services.save_new_exploration(self.author_id, exploration)
+        change_dict = {
+            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+            'property_name': exp_domain.STATE_PROPERTY_CONTENT,
+            'state_name': 'Introduction',
+            'new_value': {
+                'content_id': 'content',
+                'html': 'new suggestion'
+            },
+            'old_value': {
+                'content_id': 'content',
+                'html': expected_html_content
+            }
+        }
+
+        with self.swap(
+            feedback_models.GeneralFeedbackThreadModel,
+            'generate_new_thread_id',
+            self.mock_generate_new_exploration_thread_id):
+            suggestion_services.create_suggestion(
+                suggestion_models.SUGGESTION_TYPE_EDIT_STATE_CONTENT,
+                suggestion_models.TARGET_TYPE_EXPLORATION,
+                self.target_id, self.target_version_at_submission,
+                self.author_id, change_dict, 'test description',
+                self.reviewer_id)
+
+        job_id = (
+            suggestion_jobs_one_off.
+            SuggestionMathMigrationOneOffJob.create_new())
+        suggestion_jobs_one_off.SuggestionMathMigrationOneOffJob.enqueue(job_id)
+        self.process_and_flush_pending_tasks()
+
+        actual_output = (
+            suggestion_jobs_one_off.
+            SuggestionMathMigrationOneOffJob.get_output(job_id))
+        self.assertEqual(len(actual_output), 0)
+
+
     def test_migration_skips_suggestions_failing_validation(self):
         html_content = (
             '<p>Value</p><oppia-noninteractive-math raw_latex-with-value="&a'
diff --git a/core/domain/suggestion_registry_test.py b/core/domain/suggestion_registry_test.py
--- a/core/domain/suggestion_registry_test.py
+++ b/core/domain/suggestion_registry_test.py
@@ -401,25 +401,6 @@ def test_validate_score_type_content(self):
             'Expected the first part of score_category to be content'):
             suggestion.validate()
 
-    def test_validate_score_sub_type(self):
-        expected_suggestion_dict = self.suggestion_dict
-        suggestion = suggestion_registry.SuggestionEditStateContent(
-            expected_suggestion_dict['suggestion_id'],
-            expected_suggestion_dict['target_id'],
-            expected_suggestion_dict['target_version_at_submission'],
-            expected_suggestion_dict['status'], self.author_id,
-            self.reviewer_id, expected_suggestion_dict['change'],
-            expected_suggestion_dict['score_category'], self.fake_date)
-
-        suggestion.validate()
-
-        suggestion.score_category = 'content.invalid_score_sub_type'
-        with self.assertRaisesRegexp(
-            Exception,
-            'Expected the second part of score_category to be a valid'
-            ' category'):
-            suggestion.validate()
-
     def test_validate_change_cmd(self):
         expected_suggestion_dict = self.suggestion_dict
         suggestion = suggestion_registry.SuggestionEditStateContent(
@@ -429,7 +410,6 @@ def test_validate_change_cmd(self):
             expected_suggestion_dict['status'], self.author_id,
             self.reviewer_id, expected_suggestion_dict['change'],
             expected_suggestion_dict['score_category'], self.fake_date)
-
         suggestion.validate()
 
         suggestion.change.cmd = 'invalid_cmd'
@@ -964,25 +944,6 @@ def test_validate_score_type_translation(self):
             'Expected the first part of score_category to be translation'):
             suggestion.validate()
 
-    def test_validate_score_sub_type(self):
-        expected_suggestion_dict = self.suggestion_dict
-        suggestion = suggestion_registry.SuggestionTranslateContent(
-            expected_suggestion_dict['suggestion_id'],
-            expected_suggestion_dict['target_id'],
-            expected_suggestion_dict['target_version_at_submission'],
-            expected_suggestion_dict['status'], self.author_id,
-            self.reviewer_id, expected_suggestion_dict['change'],
-            expected_suggestion_dict['score_category'], self.fake_date)
-
-        suggestion.validate()
-
-        suggestion.score_category = 'translation.invalid_score_sub_type'
-        with self.assertRaisesRegexp(
-            Exception,
-            'Expected the second part of score_category to be a valid'
-            ' category'):
-            suggestion.validate()
-
     def test_validate_change_cmd(self):
         expected_suggestion_dict = self.suggestion_dict
         suggestion = suggestion_registry.SuggestionTranslateContent(