Repository: oppia/oppia

Issue:
Translations are not viewable in My Contributions > Translations
<!--
  - 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**
Translations are not viewable in My Contributions > Translations. Also, a console error is seen on clicking on My Contributions > Translations.

**To Reproduce**
Steps to reproduce the behavior:
 1. Go to 'https://oppiatestserver.appspot.com/contributor-dashboard'
 2. Click on 'My Contributions'
 3. Click on 'Translations' with the dev console open.
 4. Translations are not viewable in My Contributions > Translations. Also, a console error is seen on clicking on My Contributions > Translations.

**Observed behavior**
Translations are not viewable in My Contributions > Translations. Also, a console error is seen on clicking on My Contributions > Translations.

**Expected behavior**
Translation should be visible and there should not be any console errors.

**Screenshots**
![image](https://user-images.githubusercontent.com/11008603/92873189-65138700-f424-11ea-96c7-8e74546f7f7e.png)

**Desktop (please complete the following information; delete this section if the issue does not arise on desktop):**
 - OS: Ubuntu 20.04
 - Browser: Chrome 85

500 Error on Loading Contributor Dashboard Page
**To Reproduce**
Steps to reproduce the behavior:
 1. Go to https://oppiatestserver.appspot.com/contributor-dashboard

**Problem**
Console error:

```
Error: Possibly unhandled rejection: {"data":{"status_code":500,"error":"No skill exists for ID KIzgzKywbBjx"},"status":500,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"jsonpCallbackParam":"callback","url":"/getsubmittedsuggestions/skill/add_question","headers":{"Accept":"application/json, text/plain, */*"}},"statusText":"Internal Server Error","xhrStatus":"complete"}
    <anonymous> App.ts:379
    Angular 12
angular.js:15570:15
```

**Screenshots**
![Screen Shot 2020-10-08 at 08 20 06](https://user-images.githubusercontent.com/19878639/95457582-197acd00-093f-11eb-9f14-c37172caf763.png)

**Desktop (please complete the following information; delete this section if the issue does not arise on desktop):**
 - OS: macOS
 - Browser: Firefox 80 and Chrome 85


Discussion:
@kevintab95 translations show up for me:
<img width="1565" alt="Screen Shot 2020-09-11 at 4 43 37 PM" src="https://user-images.githubusercontent.com/19376937/92981271-0236e580-f44e-11ea-8a80-e1ea1078ac5e.png">

Oh interesting.. do console errors show up for you @kaylahardie?
Nope @kevintab95, no console errors
<img width="2039" alt="Screen Shot 2020-09-14 at 12 17 32 PM" src="https://user-images.githubusercontent.com/19376937/93128433-53301f00-f684-11ea-9212-3bff8e4c5c77.png">

@kevintab95 @kaylahardie The console errors show up for me, and no translations show up for me either. 
![Screen Shot 2020-09-15 at 6 33 39 PM](https://user-images.githubusercontent.com/35385366/93281988-2cf0a900-f782-11ea-8054-9d78b5a1a23b.png)

Hi @jimbyo, will you be able to fix this issue by 23rd? We're running a bit behind schedule on the release and it would be great if this issue can be fixed by then. Please let me know. Thanks!
@jimbyo friendly ping!
@kevintab95 I think so but I'll update you again later tonight

> @kevintab95 I think so but I'll update you again later tonight

Sounds good, thanks @jimbyo!
Might be duplicate of https://github.com/oppia/oppia/issues/10696
Hi @DubeySandeep, would https://github.com/oppia/oppia/pull/10751 fix this?

Resolving patch:
diff --git a/core/controllers/suggestion.py b/core/controllers/suggestion.py
--- a/core/controllers/suggestion.py
+++ b/core/controllers/suggestion.py
@@ -48,10 +48,13 @@ def _get_target_id_to_exploration_opportunity_dict(suggestions):
         summary dict.
     """
     target_ids = set([s.target_id for s in suggestions])
-    opportunities = (
-        opportunity_services.get_exploration_opportunity_summaries_by_ids(
-            list(target_ids)))
-    return {opp.id: opp.to_dict() for opp in opportunities}
+    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):
@@ -65,22 +68,25 @@ def _get_target_id_to_skill_opportunity_dict(suggestions):
         dict. Dict mapping target_id to corresponding skill opportunity dict.
     """
     target_ids = set([s.target_id for s in suggestions])
-    opportunities = (
-        opportunity_services.get_skill_opportunities_by_ids(list(target_ids)))
-    opportunity_skill_ids = [opp.id for opp in opportunities]
+    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(opportunity_skill_ids)
+        for skill in skill_fetchers.get_multi_skills([
+            opp['id']
+            for opp in opportunity_id_to_opportunity_dict.values()
+            if opp is not None])
     }
-    opportunity_id_to_opportunity = {}
-    for opp in opportunities:
-        opp_dict = opp.to_dict()
-        skill = opportunity_id_to_skill.get(opp.id)
+
+    for opp_id, skill in opportunity_id_to_skill.items():
         if skill is not None:
-            opp_dict['skill_rubrics'] = [
+            opportunity_id_to_opportunity_dict[opp_id]['skill_rubrics'] = [
                 rubric.to_dict() for rubric in skill.rubrics]
-        opportunity_id_to_opportunity[opp.id] = opp_dict
-    return opportunity_id_to_opportunity
+
+    return opportunity_id_to_opportunity_dict
 
 
 class SuggestionHandler(base.BaseHandler):
diff --git a/core/domain/opportunity_services.py b/core/domain/opportunity_services.py
--- a/core/domain/opportunity_services.py
+++ b/core/domain/opportunity_services.py
@@ -511,30 +511,25 @@ def get_voiceover_opportunities(language_code, cursor):
 
 
 def get_exploration_opportunity_summaries_by_ids(ids):
-    """Returns a list of ExplorationOpportunitySummary objects corresponding to
-    the given list of ids.
+    """Returns a dict with key as id and value representing
+    ExplorationOpportunitySummary objects corresponding to the opportunity id.
 
     Args:
         ids: list(str). A list of opportunity ids.
 
     Returns:
-        list(ExplorationOpportunitySummary). A list of
-        ExplorationOpportunitySummary domain objects corresponding to the
-        supplied ids.
+        dict(str, ExplorationOpportunitySummary|None). A dict with key as the
+        opportunity id and values representing the ExplorationOpportunitySummary
+        domain objects corresponding to the opportunity id if exist else None.
     """
+    opportunities = {opportunity_id: None for opportunity_id in ids}
     exp_opportunity_summary_models = (
         opportunity_models.ExplorationOpportunitySummaryModel.get_multi(ids))
-    opportunities = []
     for exp_opportunity_summary_model in exp_opportunity_summary_models:
         if exp_opportunity_summary_model is not None:
-            exp_opportunity_summary = (
+            opportunities[exp_opportunity_summary_model.id] = (
                 get_exploration_opportunity_summary_from_model(
                     exp_opportunity_summary_model))
-            opportunities.append(exp_opportunity_summary)
-        else:
-            logging.warning(
-                'When getting the exploration opportunity summary models for '
-                'ids: %s, one of the models was None.' % ids)
     return opportunities
 
 
@@ -616,16 +611,18 @@ def get_skill_opportunities_by_ids(ids):
         ids: list(str). A list of the opportunity ids.
 
     Returns:
-        list(SkillOpportunity). A list of SkillOpportunity domain objects
-        corresponding to the supplied ids.
+        dict(str, SkillOpportunity|None). A dict with key as the
+        opportunity id and values representing the SkillOpportunity
+        domain objects corresponding to the opportunity id if exist else None.
     """
+    opportunities = {opportunity_id: None for opportunity_id in ids}
     skill_opportunity_models = (
         opportunity_models.SkillOpportunityModel.get_multi(ids))
-    opportunities = []
+
     for skill_opportunity_model in skill_opportunity_models:
-        skill_opportunity = (
-            get_skill_opportunity_from_model(skill_opportunity_model))
-        opportunities.append(skill_opportunity)
+        if skill_opportunity_model is not None:
+            opportunities[skill_opportunity_model.id] = (
+                get_skill_opportunity_from_model(skill_opportunity_model))
     return opportunities
 
 
diff --git a/core/domain/voiceover_services.py b/core/domain/voiceover_services.py
--- a/core/domain/voiceover_services.py
+++ b/core/domain/voiceover_services.py
@@ -209,7 +209,7 @@ def accept_voiceover_application(voiceover_application_id, reviewer_id):
                 voiceover_application.target_id]))
         email_manager.send_accepted_voiceover_application_email(
             voiceover_application.author_id,
-            opportunities[0].chapter_title,
+            opportunities[voiceover_application.target_id].chapter_title,
             voiceover_application.language_code)
     # TODO(#7969): Add notification to the user's dashboard for the accepted
     # voiceover application.
@@ -261,7 +261,7 @@ def reject_voiceover_application(
                 voiceover_application.target_id]))
         email_manager.send_rejected_voiceover_application_email(
             voiceover_application.author_id,
-            opportunities[0].chapter_title,
+            opportunities[voiceover_application.target_id].chapter_title,
             voiceover_application.language_code, rejection_message)
     # TODO(#7969): Add notification to the user's dashboard for the accepted
     # voiceover application.


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
@@ -27,6 +27,7 @@
 from core.domain import exp_services
 from core.domain import feedback_services
 from core.domain import fs_domain
+from core.domain import opportunity_services
 from core.domain import question_domain
 from core.domain import question_services
 from core.domain import rights_domain
@@ -1524,6 +1525,28 @@ def test_skill_handler_returns_data(self):
             '/getsubmittedsuggestions/topic/add_question')
         self.assertEqual(response, {})
 
+    def test_question_suggestions_data_for_deleted_opportunities(self):
+        self.login(self.AUTHOR_EMAIL)
+
+        opportunity_services.delete_skill_opportunity(self.SKILL_ID)
+        response = self.get_json(
+            '/getsubmittedsuggestions/skill/add_question')
+        self.assertEqual(len(response['suggestions']), 1)
+        self.assertEqual(len(response['target_id_to_opportunity_dict']), 1)
+        self.assertEqual(
+            response['target_id_to_opportunity_dict'][self.SKILL_ID], None)
+
+    def test_translation_suggestions_data_for_deleted_opportunities(self):
+        self.login(self.AUTHOR_EMAIL)
+
+        opportunity_services.delete_exploration_opportunities([self.EXP_ID])
+        response = self.get_json(
+            '/getsubmittedsuggestions/exploration/translate_content')
+        self.assertEqual(len(response['suggestions']), 1)
+        self.assertEqual(len(response['target_id_to_opportunity_dict']), 1)
+        self.assertEqual(
+            response['target_id_to_opportunity_dict'][self.EXP_ID], None)
+
     def test_handler_with_invalid_suggestion_type_raise_error(self):
         self.login(self.AUTHOR_EMAIL)
 
diff --git a/core/domain/opportunity_services_test.py b/core/domain/opportunity_services_test.py
--- a/core/domain/opportunity_services_test.py
+++ b/core/domain/opportunity_services_test.py
@@ -795,12 +795,12 @@ def setUp(self):
                 'new_value': '0'
             })], 'Changes.')
 
-    def test_get_exploration_opportunity_summaries_by_ids_returns_list_of_objects(self): # pylint: disable=line-too-long
+    def test_get_exploration_opportunity_summaries_by_ids(self):
         output = (
             opportunity_services.get_exploration_opportunity_summaries_by_ids(
                 []))
 
-        self.assertEqual(output, [])
+        self.assertEqual(output, {})
 
         opportunities = (
             opportunity_services.get_exploration_opportunity_summaries_by_ids(
@@ -808,38 +808,17 @@ def test_get_exploration_opportunity_summaries_by_ids_returns_list_of_objects(se
 
         self.assertEqual(len(opportunities), 1)
         self.assertIsInstance(
-            opportunities[0],
+            opportunities['0'],
             opportunity_domain.ExplorationOpportunitySummary)
-        self.assertEqual(opportunities[0].id, '0')
+        self.assertEqual(opportunities['0'].id, '0')
 
-    def test_get_exploration_opportunity_summaries_by_ids_warns_if_invalid_id(
-            self):
-        """Tests that a warning is logged if there is no
-        ExplorationOpportunitySummaryModel associated with one of the ids.
-        """
-        observed_log_messages = []
-
-        def _mock_logging_function(msg, *args):
-            """Mocks logging.warning()."""
-            observed_log_messages.append(msg % args)
-
-        logging_swap = self.swap(logging, 'warning', _mock_logging_function)
+    def test_get_exploration_opportunity_summaries_by_ids_for_invalid_id(self):
+        opportunities = (
+            opportunity_services.get_exploration_opportunity_summaries_by_ids(
+                ['badID']))
 
-        with logging_swap:
-            opportunities = (
-                opportunity_services.
-                get_exploration_opportunity_summaries_by_ids(
-                    ['badID'])
-            )
-
-        self.assertEqual(len(opportunities), 0)
-        self.assertEqual(
-            observed_log_messages,
-            [
-                'When getting the exploration opportunity summary models for '
-                'ids: [u\'badID\'], one of the models was None.'
-            ]
-        )
+        self.assertEqual(len(opportunities), 1)
+        self.assertEqual(opportunities['badID'], None)
 
     def test_get_exploration_opportunity_summary_from_model_populates_new_lang(
             self):
@@ -854,7 +833,7 @@ def _mock_logging_function(msg, *args):
                 ['0']))
         self.assertEqual(len(opportunities), 1)
 
-        opportunity = opportunities[0]
+        opportunity = opportunities['0']
 
         self.assertFalse(
             'new_lang' in opportunity.incomplete_translation_language_codes)
@@ -874,7 +853,7 @@ def _mock_logging_function(msg, *args):
                 .get_exploration_opportunity_summaries_by_ids(['0']))
             self.assertEqual(len(opportunities), 1)
 
-            opportunity = opportunities[0]
+            opportunity = opportunities['0']
 
             self.assertTrue(
                 'new_lang' in opportunity.incomplete_translation_language_codes)