Repository: oppia/oppia

Issue:
[Contributor Dashboard] Server error when retrieving translations to review
<!--
  - Thanks for taking the time to report a bug in the Oppia project.
  - Before filing a new issue, please do a quick search to check that it hasn't
  - already been filed on the [issue tracker](https://github.com/oppia/oppia/issues)._
  -->

**Describe the bug**
An error comes when retrieving translations to review saying that `Content ID <content_id> does not exist`. It's blocking the whole translation review process.

**To Reproduce**
Steps to reproduce the behavior:
 1. Go to /contributor-dashboard
 2. Submit a translation for any card
 3. Go to /community-library
 4. Click on the exploration that the translation was submitted
 5. Click on the Edit button
 ![image](https://user-images.githubusercontent.com/41409951/143861872-d213da66-44ec-47ed-9339-c23b37620bae.png)
 6. Go to the Editor
 7. Remove the content that the intial translation was submitted to.
 8. Publish changes
 9. Go back to  /contributor-dashboard as a reviewer to review the translation.
 10. See error
 

**Observed behavior**
Error log

`2021-11-28 12:39:56.090 PSTException raised: Content ID feedback_2 does not exist Traceback (most recent call last): File "/workspace/third_party/python_libs/webapp2.py", line 604, in dispatch return method(*args, **kwargs) File "/workspace/core/controllers/acl_decorators.py", line 3405, in test_can_view_reviewable_suggestions return handler(self, target_type, suggestion_type, **kwargs) File "/workspace/core/controllers/suggestion.py", line 278, in get self._render_suggestions(target_type, suggestions) File "/workspace/core/controllers/suggestion.py", line 245, in _render_suggestions 'suggestions': _construct_exploration_suggestions(suggestions), File "/workspace/core/controllers/suggestion.py", line 489, in _construct_exploration_suggestions suggestion.change.state_name, suggestion.change.content_id) File "/workspace/core/domain/exp_domain.py", line 1249, in get_content_html return self.states[state_name].get_content_html(content_id) File "/workspace/core/domain/state_domain.py", line 2605, in get_content_html raise ValueError('Content ID %s does not exist' % content_id) ValueError: Content ID feedback_2 does not exist`

**Expected behavior**
Review process should not be blocked.

**Screenshots**
![Screenshot from 2021-11-28 12-40-56](https://user-images.githubusercontent.com/41409951/143861407-258ced64-3e24-4b44-8ed8-f7daa106a1a9.png)

![Screenshot from 2021-11-28 12-41-02](https://user-images.githubusercontent.com/41409951/143861422-9d9dcd2f-0b43-4418-bccc-f476859cf866.png)

[Contributor Dashboard] Server error when retrieving translations to review
<!--
  - Thanks for taking the time to report a bug in the Oppia project.
  - Before filing a new issue, please do a quick search to check that it hasn't
  - already been filed on the [issue tracker](https://github.com/oppia/oppia/issues)._
  -->

**Describe the bug**
An error comes when retrieving translations to review saying that `Content ID <content_id> does not exist`. It's blocking the whole translation review process.

**To Reproduce**
Steps to reproduce the behavior:
 1. Go to /contributor-dashboard
 2. Submit a translation for any card
 3. Go to /community-library
 4. Click on the exploration that the translation was submitted
 5. Click on the Edit button
 ![image](https://user-images.githubusercontent.com/41409951/143861872-d213da66-44ec-47ed-9339-c23b37620bae.png)
 6. Go to the Editor
 7. Remove the content that the intial translation was submitted to.
 8. Publish changes
 9. Go back to  /contributor-dashboard as a reviewer to review the translation.
 10. See error
 

**Observed behavior**
Error log

`2021-11-28 12:39:56.090 PSTException raised: Content ID feedback_2 does not exist Traceback (most recent call last): File "/workspace/third_party/python_libs/webapp2.py", line 604, in dispatch return method(*args, **kwargs) File "/workspace/core/controllers/acl_decorators.py", line 3405, in test_can_view_reviewable_suggestions return handler(self, target_type, suggestion_type, **kwargs) File "/workspace/core/controllers/suggestion.py", line 278, in get self._render_suggestions(target_type, suggestions) File "/workspace/core/controllers/suggestion.py", line 245, in _render_suggestions 'suggestions': _construct_exploration_suggestions(suggestions), File "/workspace/core/controllers/suggestion.py", line 489, in _construct_exploration_suggestions suggestion.change.state_name, suggestion.change.content_id) File "/workspace/core/domain/exp_domain.py", line 1249, in get_content_html return self.states[state_name].get_content_html(content_id) File "/workspace/core/domain/state_domain.py", line 2605, in get_content_html raise ValueError('Content ID %s does not exist' % content_id) ValueError: Content ID feedback_2 does not exist`

**Expected behavior**
Review process should not be blocked.

**Screenshots**
![Screenshot from 2021-11-28 12-40-56](https://user-images.githubusercontent.com/41409951/143861407-258ced64-3e24-4b44-8ed8-f7daa106a1a9.png)

![Screenshot from 2021-11-28 12-41-02](https://user-images.githubusercontent.com/41409951/143861422-9d9dcd2f-0b43-4418-bccc-f476859cf866.png)



Discussion:

Resolving patch:
diff --git a/core/controllers/suggestion.py b/core/controllers/suggestion.py
--- a/core/controllers/suggestion.py
+++ b/core/controllers/suggestion.py
@@ -478,18 +478,34 @@ def _construct_exploration_suggestions(suggestions):
     Returns:
         list(dict). List of suggestion dicts with an additional
         exploration_content_html field representing the target
-        exploration's current content.
+        exploration's current content. If the given suggestion refers to an
+        invalid content ID in the current exploration (this can happen if that
+        content was deleted after the suggestion was made), the corresponding
+        suggestion dict will be omitted from the return value.
     """
     exp_ids = {suggestion.target_id for suggestion in suggestions}
     exp_id_to_exp = exp_fetchers.get_multiple_explorations_by_id(list(exp_ids))
 
     suggestion_dicts = []
     for suggestion in suggestions:
-        content_html = exp_id_to_exp[suggestion.target_id].get_content_html(
-            suggestion.change.state_name, suggestion.change.content_id)
-        suggestion_dict = suggestion.to_dict()
-        suggestion_dict['exploration_content_html'] = content_html
-        suggestion_dicts.append(suggestion_dict)
+        available_states = exp_id_to_exp[suggestion.target_id].states
+        content_id_exists = False
+
+        # Checks whether the state name within change object of the suggestion
+        # is actually available in the target entity being suggested to and
+        # then find the availability of the content ID in the translatable
+        # content. See more - https://github.com/oppia/oppia/issues/14339
+        if suggestion.change.state_name in available_states:
+            content_id_exists = available_states[
+                suggestion.change.state_name].has_content_id(
+                    suggestion.change.content_id)
+
+        if content_id_exists:
+            content_html = exp_id_to_exp[suggestion.target_id].get_content_html(
+                suggestion.change.state_name, suggestion.change.content_id)
+            suggestion_dict = suggestion.to_dict()
+            suggestion_dict['exploration_content_html'] = content_html
+            suggestion_dicts.append(suggestion_dict)
     return suggestion_dicts
 
 
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
@@ -3257,6 +3257,21 @@ def _get_all_translatable_content(self):
 
         return content_id_to_translatable_item
 
+    def has_content_id(self, content_id):
+        """Returns whether a given content ID is available in the translatable
+        content.
+
+        Args:
+            content_id: str. The content ID that needs to be checked for the
+                availability.
+
+        Returns:
+            bool. A boolean that indicates the availability of the content ID
+            in the translatable content.
+        """
+        available_translate_content = self._get_all_translatable_content()
+        return bool(content_id in available_translate_content)
+
     def get_content_id_mapping_needing_translations(self, language_code):
         """Returns all text html which can be translated in the given language.
 
diff --git a/core/feconf.py b/core/feconf.py
--- a/core/feconf.py
+++ b/core/feconf.py
@@ -209,7 +209,7 @@
 
 # The maximum number of results to retrieve in a datastore query
 # for suggestions.
-DEFAULT_SUGGESTION_QUERY_LIMIT = 100
+DEFAULT_SUGGESTION_QUERY_LIMIT = 1000
 
 # The maximum number of results to retrieve in a datastore query
 # for top rated published explorations in /library page.


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
@@ -2439,6 +2439,232 @@ def test_exploration_handler_returns_data(self):
             }
         )
 
+    def test_get_reviewable_suggestions_when_state_of_a_target_is_removed(
+        self):
+        exploration = self.save_new_valid_exploration(
+            'exp2', self.owner_id, objective='The objective')
+        init_state = exploration.states[exploration.init_state_name]
+        default_outcome_dict = init_state.interaction.default_outcome.to_dict()
+        default_outcome_dict['dest'] = exploration.init_state_name
+        exp_services.update_exploration(
+            self.owner_id, 'exp2', [
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name': (
+                        exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
+                    'state_name': exploration.init_state_name,
+                    'new_value': default_outcome_dict
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_ADD_STATE,
+                    'state_name': 'New state',
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
+                    'state_name': 'New state',
+                    'new_value': 'MultipleChoiceInput'
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name':
+                        exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
+                    'state_name': 'New state',
+                    'new_value': {
+                        'choices': {
+                            'value': [{
+                                'content_id': 'ca_choices_0',
+                                'html': '<p>Option A</p>'
+                            }, {
+                                'content_id': 'ca_choices_1',
+                                'html': '<p>Option B</p>'
+                            }]
+                        },
+                        'showChoicesInShuffledOrder': {'value': False}
+                    }
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name':
+                        exp_domain.STATE_PROPERTY_NEXT_CONTENT_ID_INDEX,
+                    'state_name': 'New state',
+                    'new_value': 1
+                })], 'Add state name')
+
+        self.logout()
+        self.login(self.AUTHOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        updated_exploration = exp_fetchers.get_exploration_by_id('exp2')
+        self.translate_suggestion_change = {
+            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
+            'state_name': 'New state',
+            'content_id': 'ca_choices_0',
+            'language_code': 'hi',
+            'content_html': '<p>Option A</p>',
+            'translation_html': '<p>new content html in Hindi</p>',
+            'data_format': 'html'
+        }
+        self.post_json(
+            '%s/' % feconf.SUGGESTION_URL_PREFIX, {
+                'suggestion_type': (
+                    feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT),
+                'target_type': feconf.ENTITY_TYPE_EXPLORATION,
+                'target_id': 'exp2',
+                'target_version_at_submission': updated_exploration.version,
+                'change': self.translate_suggestion_change,
+                'description': 'Adds translation',
+            }, csrf_token=csrf_token
+        )
+
+        self.logout()
+
+        self.login(self.REVIEWER_EMAIL)
+        response = self.get_json(
+            '/getreviewablesuggestions/exploration/translate_content')
+
+        # Since there is a properly created translation in the setup and another
+        # one in this test case, there should be 2 suggestions when it is
+        # requested for available translations.
+        self.assertEqual(len(response['suggestions']), 2)
+
+        self.logout()
+
+        exp_services.update_exploration(
+            self.owner_id, 'exp2', [exp_domain.ExplorationChange({
+                'cmd': exp_domain.CMD_DELETE_STATE,
+                'state_name': 'New state',
+            })], 'delete state')
+
+        self.login(self.REVIEWER_EMAIL)
+        response = self.get_json(
+            '/getreviewablesuggestions/exploration/translate_content')
+
+        # Now the state of the exploration created in this case is deleted.
+        # Therefore only one translation should be retrieved.
+        self.assertEqual(len(response['suggestions']), 1)
+
+        self.logout()
+
+    def test_get_reviewable_suggestions_when_original_content_is_removed(self):
+        exploration = self.save_new_valid_exploration(
+            'exp2', self.owner_id, objective='The objective')
+        init_state = exploration.states[exploration.init_state_name]
+        init_state.update_next_content_id_index(3)
+        default_outcome_dict = init_state.interaction.default_outcome.to_dict()
+        default_outcome_dict['dest'] = exploration.init_state_name
+        exp_services.update_exploration(
+            self.owner_id, 'exp2', [
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name': (
+                        exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME),
+                    'state_name': exploration.init_state_name,
+                    'new_value': default_outcome_dict
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_ADD_STATE,
+                    'state_name': 'New state',
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name': exp_domain.STATE_PROPERTY_INTERACTION_ID,
+                    'state_name': 'New state',
+                    'new_value': 'MultipleChoiceInput'
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name':
+                        exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
+                    'state_name': 'New state',
+                    'new_value': {
+                        'choices': {
+                            'value': [{
+                                'content_id': 'ca_choices_0',
+                                'html': '<p>Option A</p>'
+                            }, {
+                                'content_id': 'ca_choices_1',
+                                'html': '<p>Option B</p>'
+                            }]
+                        },
+                        'showChoicesInShuffledOrder': {'value': False}
+                    }
+                }),
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name':
+                        exp_domain.STATE_PROPERTY_NEXT_CONTENT_ID_INDEX,
+                    'state_name': 'New state',
+                    'new_value': 1
+                })], 'Add state name')
+
+        self.logout()
+        self.login(self.AUTHOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        updated_exploration = exp_fetchers.get_exploration_by_id('exp2')
+        self.translate_suggestion_change = {
+            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
+            'state_name': 'New state',
+            'content_id': 'ca_choices_1',
+            'language_code': 'hi',
+            'content_html': '<p>Option B</p>',
+            'translation_html': '<p>new content html in Hindi</p>',
+            'data_format': 'html'
+        }
+        self.post_json(
+            '%s/' % feconf.SUGGESTION_URL_PREFIX, {
+                'suggestion_type': (
+                    feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT),
+                'target_type': feconf.ENTITY_TYPE_EXPLORATION,
+                'target_id': 'exp2',
+                'target_version_at_submission': updated_exploration.version,
+                'change': self.translate_suggestion_change,
+                'description': 'Adds translation',
+            }, csrf_token=csrf_token
+        )
+
+        self.logout()
+
+        # Since there is a properly created translation in the setup and another
+        # one in this test case, there should be 2 suggestions when it is
+        # requested for available translations.
+        self.login(self.REVIEWER_EMAIL)
+        response = self.get_json(
+            '/getreviewablesuggestions/exploration/translate_content')
+
+        self.assertEqual(len(response['suggestions']), 2)
+
+        self.logout()
+
+        exp_services.update_exploration(
+            self.owner_id, 'exp2', [
+                exp_domain.ExplorationChange({
+                    'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+                    'property_name':
+                        exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS,
+                    'state_name': 'New state',
+                    'new_value': {
+                        'choices': {
+                            'value': [{
+                                'content_id': 'ca_choices_0',
+                                'html': '<p>Option A</p>'
+                            }]
+                        },
+                        'showChoicesInShuffledOrder': {'value': False}
+                    }
+                })], 'Add state name')
+
+        # Now the original content that the translation was made does not exist.
+        # Therefore only one translation should be retrieved.
+        self.login(self.REVIEWER_EMAIL)
+        response = self.get_json(
+            '/getreviewablesuggestions/exploration/translate_content')
+
+        self.assertEqual(len(response['suggestions']), 1)
+
+        self.logout()
+
     def test_topic_translate_handler_returns_no_data(self):
         response = self.get_json(
             '/getreviewablesuggestions/topic/translate_content')
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
@@ -1604,6 +1604,16 @@ def test_get_content_id_mapping_needing_translations_does_not_return_numeric_con
         self.assertFalse(
             'ca_choices_2' in content_id_mapping_needing_translations)
 
+    def test_content_id_existance_checks_work_correctly(self):
+        exploration = exp_domain.Exploration.create_default_exploration('0')
+        init_state = exploration.states[exploration.init_state_name]
+
+        self.assertEqual(init_state.has_content_id('content'), True)
+        with self.assertRaisesRegexp(
+            ValueError, 'Content ID content0 does not exist'):
+            init_state.get_content_html('content0')
+        self.assertEqual(init_state.has_content_id('content0'), False)
+
     def test_add_translation_works_correctly(self):
         exploration = exp_domain.Exploration.create_default_exploration('0')
         init_state = exploration.states[exploration.init_state_name]