Repository: oppia/oppia

Issue:
400 error when trying to compare older versions of explorations
Sometimes it is not possible to compare old versions of explorations in the exploration history tab, such as for Ratios 1 version 546 and 547. The error trace looks like this (currently causing a breakage in production):

![Screenshot from 2022-01-24 11-00-53](https://user-images.githubusercontent.com/10575562/150715094-e3b90f6f-c178-4bf9-9cd1-f430097c79c4.png)

I tracked the error down to this line: https://github.com/oppia/oppia/blob/develop/core/domain/state_domain.py#L1467 . The problem seems to be that 0 is not considered a float. We should probably be doing a comparison against numbers.Number or similar.

It would also be good to check all our other "isinstance ... float" comparisons and update them if needed.

400 error when trying to compare older versions of explorations
Sometimes it is not possible to compare old versions of explorations in the exploration history tab, such as for Ratios 1 version 546 and 547. The error trace looks like this (currently causing a breakage in production):

![Screenshot from 2022-01-24 11-00-53](https://user-images.githubusercontent.com/10575562/150715094-e3b90f6f-c178-4bf9-9cd1-f430097c79c4.png)

I tracked the error down to this line: https://github.com/oppia/oppia/blob/develop/core/domain/state_domain.py#L1467 . The problem seems to be that 0 is not considered a float. We should probably be doing a comparison against numbers.Number or similar.

It would also be good to check all our other "isinstance ... float" comparisons and update them if needed.



Discussion:
@vojtechjelinek can this be hotfixed? Assigning you since the comparison pattern described here is likely something to be looked at codebase-wide.
@vojtechjelinek can this be hotfixed? Assigning you since the comparison pattern described here is likely something to be looked at codebase-wide.

Resolving patch:
diff --git a/core/controllers/suggestion.py b/core/controllers/suggestion.py
--- a/core/controllers/suggestion.py
+++ b/core/controllers/suggestion.py
@@ -458,7 +458,7 @@ def post(self, suggestion_id):
         if suggestion.is_handled:
             raise self.InvalidInputException(
                 'The suggestion with id %s has been accepted or rejected'
-                % (suggestion_id)
+                % suggestion_id
             )
 
         if self.payload.get('skill_difficulty') is None:
@@ -466,7 +466,7 @@ def post(self, suggestion_id):
                 'The parameter \'skill_difficulty\' is missing.'
             )
 
-        if not isinstance(self.payload.get('skill_difficulty'), float):
+        if not isinstance(self.payload.get('skill_difficulty'), (float, int)):
             raise self.InvalidInputException(
                 'The parameter \'skill_difficulty\' should be a decimal.'
             )
diff --git a/core/domain/exp_domain.py b/core/domain/exp_domain.py
--- a/core/domain/exp_domain.py
+++ b/core/domain/exp_domain.py
@@ -2621,7 +2621,7 @@ def validate(self):
                     'Expected value to be non-negative, received %s' % (
                         value))
 
-        if not isinstance(self.scaled_average_rating, float):
+        if not isinstance(self.scaled_average_rating, (float, int)):
             raise utils.ValidationError(
                 'Expected scaled_average_rating to be float, received %s' % (
                     self.scaled_average_rating))
diff --git a/core/domain/state_domain.py b/core/domain/state_domain.py
--- a/core/domain/state_domain.py
+++ b/core/domain/state_domain.py
@@ -1464,7 +1464,7 @@ def validate(self):
             raise utils.ValidationError(
                 'Expected needs_update to be a bool, received %s' %
                 self.needs_update)
-        if not isinstance(self.duration_secs, float):
+        if not isinstance(self.duration_secs, (float, int)):
             raise utils.ValidationError(
                 'Expected duration_secs to be a float, received %s' %
                 self.duration_secs)


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
@@ -931,12 +931,9 @@ def test_cannot_update_translation_with_invalid_translation_html(self):
 
     def test_update_suggestion_updates_question_suggestion_content(self):
         skill_id = skill_services.get_new_skill_id()
-        self.save_new_skill(
-            skill_id, self.author_id, description='description')
+        self.save_new_skill(skill_id, self.author_id, description='description')
         suggestion_change = {
-            'cmd': (
-                question_domain
-                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'cmd': question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION,
             'question_dict': {
                 'question_state_data': self._create_valid_question_data(
                     'default_state').to_dict(),
@@ -964,31 +961,34 @@ 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>'
-                '<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
+        question_state_data['content']['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:
+            'rb',
+            encoding=None
+        ) as f:
             raw_image = f.read()
 
-        self.post_json('%s/%s' % (
-            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
-            suggestion.suggestion_id), {
+        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, upload_files=(
-                ('img.png', 'img.png', raw_image),))
+            csrf_token=csrf_token,
+            upload_files=(('img.png', 'img.png', raw_image),)
+        )
 
         updated_suggestion = suggestion_services.get_suggestion_by_id(
             suggestion.suggestion_id)
@@ -996,28 +996,23 @@ def test_update_suggestion_updates_question_suggestion_content(self):
             'question_state_data']
 
         self.assertEqual(
-            new_question_state_data['content'][
-                '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>'))
+            new_question_state_data['content']['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>'
+        )
         self.assertEqual(
-            new_question_state_data['interaction'][
-                'solution'],
+            new_question_state_data['interaction']['solution'],
             new_solution_dict)
         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')
+        self.save_new_skill(skill_id, self.author_id, description='description')
         suggestion_change = {
-            'cmd': (
-                question_domain
-                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'cmd': question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION,
             'question_dict': {
                 'question_state_data': self._create_valid_question_data(
                     'default_state').to_dict(),
@@ -1037,8 +1032,7 @@ def test_cannot_update_question_with_invalid_skill_difficulty(self):
 
         question_state_data = suggestion.change.question_dict[
             'question_state_data']
-        question_state_data['content'][
-            'html'] = '<p>Updated question</p>'
+        question_state_data['content']['html'] = '<p>Updated question</p>'
         new_solution_dict = {
             'answer_is_exclusive': False,
             'correct_answer': 'Solution',
@@ -1047,18 +1041,23 @@ 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
+        question_state_data['interaction']['solution'] = new_solution_dict
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.post_json('%s/%s' % (
-            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
-            suggestion.suggestion_id), {
+        response = 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)
+            },
+            csrf_token=csrf_token,
+            expected_status_int=400
+        )
 
         self.assertEqual(
             response['error'],
@@ -1067,12 +1066,9 @@ def test_cannot_update_question_with_invalid_skill_difficulty(self):
 
     def test_cannot_update_question_without_state_data(self):
         skill_id = skill_services.get_new_skill_id()
-        self.save_new_skill(
-            skill_id, self.author_id, description='description')
+        self.save_new_skill(skill_id, self.author_id, description='description')
         suggestion_change = {
-            'cmd': (
-                question_domain
-                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'cmd': question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION,
             'question_dict': {
                 'question_state_data': self._create_valid_question_data(
                     'default_state').to_dict(),
@@ -1100,19 +1096,23 @@ def test_cannot_update_question_without_state_data(self):
 
         question_state_data = suggestion.change.question_dict[
             'question_state_data']
-        question_state_data['content'][
-            'html'] = '<p>Updated question</p>'
-        question_state_data['interaction'][
-            'solution'] = new_solution_dict
+        question_state_data['content']['html'] = '<p>Updated question</p>'
+        question_state_data['interaction']['solution'] = new_solution_dict
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.post_json('%s/%s' % (
-            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
-            suggestion.suggestion_id), {
+        response = self.post_json(
+            '%s/%s' % (
+                feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
+                suggestion.suggestion_id
+            ),
+            {
                 'skill_difficulty': 0.6
-            }, csrf_token=csrf_token, expected_status_int=400)
+            },
+            csrf_token=csrf_token,
+            expected_status_int=400
+        )
 
         self.assertEqual(
             response['error'],
@@ -1124,9 +1124,7 @@ def test_cannot_update_question_without_skill_difficulty(self):
         self.save_new_skill(
             skill_id, self.author_id, description='description')
         suggestion_change = {
-            'cmd': (
-                question_domain
-                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'cmd': question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION,
             'question_dict': {
                 'question_state_data': self._create_valid_question_data(
                     'default_state').to_dict(),
@@ -1154,23 +1152,26 @@ def test_cannot_update_question_without_skill_difficulty(self):
 
         question_state_data = suggestion.change.question_dict[
             'question_state_data']
-        question_state_data['content'][
-            'html'] = '<p>Updated question</p>'
-        question_state_data['interaction'][
-            'solution'] = new_solution_dict
+        question_state_data['content']['html'] = '<p>Updated question</p>'
+        question_state_data['interaction']['solution'] = new_solution_dict
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.post_json('%s/%s' % (
-            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
-            suggestion.suggestion_id), {
+        response = self.post_json(
+            '%s/%s' % (
+                feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
+                suggestion.suggestion_id
+            ),
+            {
                 'question_state_data': question_state_data
-            }, csrf_token=csrf_token, expected_status_int=400)
+            },
+            csrf_token=csrf_token,
+            expected_status_int=400
+        )
 
         self.assertEqual(
-            response['error'],
-            'The parameter \'skill_difficulty\' is missing.')
+            response['error'], 'The parameter \'skill_difficulty\' is missing.')
         self.logout()
 
     def test_cannot_update_already_handled_question(self):
@@ -1178,9 +1179,7 @@ def test_cannot_update_already_handled_question(self):
         self.save_new_skill(
             skill_id, self.author_id, description='description')
         suggestion_change = {
-            'cmd': (
-                question_domain
-                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'cmd': question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION,
             'question_dict': {
                 'question_state_data': self._create_valid_question_data(
                     'default_state').to_dict(),
@@ -1211,25 +1210,31 @@ def test_cannot_update_already_handled_question(self):
 
         question_state_data = suggestion.change.question_dict[
             'question_state_data']
-        question_state_data['content'][
-            'html'] = '<p>Updated question</p>'
-        question_state_data['interaction'][
-            'solution'] = new_solution_dict
+        question_state_data['content']['html'] = '<p>Updated question</p>'
+        question_state_data['interaction']['solution'] = new_solution_dict
 
         self.login(self.CURRICULUM_ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        response = self.post_json('%s/%s' % (
-            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
-            suggestion.suggestion_id), {
+        response = 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)
+            },
+            csrf_token=csrf_token,
+            expected_status_int=400
+        )
 
         self.assertEqual(
             response['error'],
             'The suggestion with id %s has been accepted or rejected' % (
-                suggestion.suggestion_id))
+                suggestion.suggestion_id
+            )
+        )
         self.logout()
 
     def test_cannot_update_question_when_provided_state_data_is_invalid(self):
@@ -1237,9 +1242,7 @@ def test_cannot_update_question_when_provided_state_data_is_invalid(self):
         self.save_new_skill(
             skill_id, self.author_id, description='description')
         suggestion_change = {
-            'cmd': (
-                question_domain
-                .CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION),
+            'cmd': question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION,
             'question_dict': {
                 'question_state_data': self._create_valid_question_data(
                     'default_state').to_dict(),
@@ -1262,12 +1265,18 @@ 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.post_json('%s/%s' % (
-            feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
-            suggestion.suggestion_id), {
+        self.post_json(
+            '%s/%s' % (
+                feconf.UPDATE_QUESTION_SUGGESTION_URL_PREFIX,
+                suggestion.suggestion_id
+            ),
+            {
                 'question_state_data': invalid_question_state_data,
                 'skill_difficulty': '0.6'
-            }, csrf_token=csrf_token, expected_status_int=400)
+            },
+            csrf_token=csrf_token,
+            expected_status_int=400
+        )
         self.logout()
 
     def test_suggestion_creation_when_images_are_not_provided(self):
diff --git a/core/domain/exp_domain_test.py b/core/domain/exp_domain_test.py
--- a/core/domain/exp_domain_test.py
+++ b/core/domain/exp_domain_test.py
@@ -2465,11 +2465,17 @@ def test_validation_fails_with_invalid_value_for_ratings(self):
             'Expected value to be non-negative, received -1'):
             self.exp_summary.validate()
 
+    def test_validation_passes_with_int_scaled_average_rating(self):
+        self.exp_summary.scaled_average_rating = 1
+        self.exp_summary.validate()
+        self.assertEqual(self.exp_summary.scaled_average_rating, 1)
+
     def test_validation_fails_with_invalid_scaled_average_rating(self):
         self.exp_summary.scaled_average_rating = 'one'
         with self.assertRaisesRegex(
             utils.ValidationError,
-            'Expected scaled_average_rating to be float, received one'):
+            'Expected scaled_average_rating to be float, received one'
+        ):
             self.exp_summary.validate()
 
     def test_validation_fails_with_invalid_status(self):
diff --git a/core/domain/state_domain_test.py b/core/domain/state_domain_test.py
--- a/core/domain/state_domain_test.py
+++ b/core/domain/state_domain_test.py
@@ -3468,56 +3468,56 @@ def test_voiceover_validation(self):
 
         with self.assertRaisesRegex(
             utils.ValidationError, 'Expected audio filename to be a string'
-            ):
+        ):
             with self.swap(audio_voiceover, 'filename', 20):
                 audio_voiceover.validate()
         with self.assertRaisesRegex(
             utils.ValidationError, 'Invalid audio filename'
-            ):
+        ):
             with self.swap(audio_voiceover, 'filename', '.invalidext'):
                 audio_voiceover.validate()
         with self.assertRaisesRegex(
             utils.ValidationError, 'Invalid audio filename'
-            ):
+        ):
             with self.swap(audio_voiceover, 'filename', 'justanextension'):
                 audio_voiceover.validate()
         with self.assertRaisesRegex(
             utils.ValidationError, 'Invalid audio filename'
-            ):
+        ):
             with self.swap(audio_voiceover, 'filename', 'a.invalidext'):
                 audio_voiceover.validate()
 
         with self.assertRaisesRegex(
             utils.ValidationError, 'Expected file size to be an int'
-            ):
+        ):
             with self.swap(audio_voiceover, 'file_size_bytes', 'abc'):
                 audio_voiceover.validate()
         with self.assertRaisesRegex(
             utils.ValidationError, 'Invalid file size'
-            ):
+        ):
             with self.swap(audio_voiceover, 'file_size_bytes', -3):
                 audio_voiceover.validate()
 
         with self.assertRaisesRegex(
             utils.ValidationError, 'Expected needs_update to be a bool'
-            ):
+        ):
             with self.swap(audio_voiceover, 'needs_update', 'hello'):
                 audio_voiceover.validate()
         with self.assertRaisesRegex(
             utils.ValidationError, 'Expected duration_secs to be a float'
-            ):
+        ):
             with self.swap(audio_voiceover, 'duration_secs', 'test'):
                 audio_voiceover.validate()
         with self.assertRaisesRegex(
             utils.ValidationError, 'Expected duration_secs to be a float'
-            ):
-            with self.swap(audio_voiceover, 'duration_secs', 10):
+        ):
+            with self.swap(audio_voiceover, 'duration_secs', '10'):
                 audio_voiceover.validate()
         with self.assertRaisesRegex(
             utils.ValidationError,
             'Expected duration_secs to be positive number, '
             'or zero if not yet specified'
-            ):
+        ):
             with self.swap(audio_voiceover, 'duration_secs', -3.45):
                 audio_voiceover.validate()
 
@@ -3534,19 +3534,19 @@ def test_written_translation_validation(self):
 
         with self.assertRaisesRegex(
             utils.ValidationError, 'Expected needs_update to be a bool'
-            ):
+        ):
             with self.swap(written_translation, 'needs_update', 20):
                 written_translation.validate()
 
         with self.assertRaisesRegex(
             utils.ValidationError, 'Invalid data_format'
-            ):
+        ):
             with self.swap(written_translation, 'data_format', 'int'):
                 written_translation.validate()
 
         with self.assertRaisesRegex(
             utils.ValidationError, 'Invalid data_format'
-            ):
+        ):
             with self.swap(written_translation, 'data_format', 2):
                 written_translation.validate()
 
@@ -5109,7 +5109,7 @@ def test_validate_non_bool_needs_update(self):
             Exception, 'Expected needs_update to be a bool'):
             self.voiceover.validate()
 
-    def test_validate_float_duration_secs(self):
+    def test_validate_str_duration_secs(self):
         self.voiceover.validate()
         self.voiceover.duration_secs = 'duration_secs'
         with self.assertRaisesRegex(
@@ -5119,9 +5119,14 @@ def test_validate_float_duration_secs(self):
     def test_validate_int_duration_secs(self):
         self.voiceover.validate()
         self.voiceover.duration_secs = 10
-        with self.assertRaisesRegex(
-            Exception, 'Expected duration_secs to be a float'):
-            self.voiceover.validate()
+        self.voiceover.validate()
+        self.assertEqual(self.voiceover.duration_secs, 10)
+
+    def test_validate_float_duration_secs(self):
+        self.voiceover.validate()
+        self.voiceover.duration_secs = 10.5
+        self.voiceover.validate()
+        self.assertEqual(self.voiceover.duration_secs, 10.5)
 
     def test_validate_negative_duration_seconds(self):
         self.voiceover.validate()