Repository: oppia/oppia

Issue:
[Contributor Dashboard]  Improve status of translations progression
**Problem description**
Translators, while translating a chapter, would like to know how many translations are still missing.

**Solution description**
It may be a good idea to show on the translation cards how many cards are still missing, as well as showing on the progress bar how many translations are waiting for revisions.

Missing translations: 
In the suggestion image below, the numbers of missing translations was placed next to the chapter name to be easily related to it.
![cards left](https://user-images.githubusercontent.com/59513204/119356006-4dabbf80-bc7c-11eb-920a-f6bb0ff04b96.png)

Progress bar: 
In the suggestion image below, we have a new color (light green) to represent the status of the translations awaiting review, and tooltips to legend what color represents what (accepted, waiting reviews and untranslated).
![progress bar](https://user-images.githubusercontent.com/59513204/119356330-b004c000-bc7c-11eb-960e-b79161735db2.png)




**Other alternatives considered**

<img width="811" alt="Captura de Tela 2021-05-24 às 10 56 54" src="https://user-images.githubusercontent.com/59513204/119358540-1854a100-bc7f-11eb-95c5-94d13c891c4f.png">

<img width="586" alt="Captura de Tela 2021-05-24 às 10 58 26" src="https://user-images.githubusercontent.com/59513204/119358593-230f3600-bc7f-11eb-9107-8dc2cc322b85.png">

<img width="586" alt="Captura de Tela 2021-05-24 às 10 58 34" src="https://user-images.githubusercontent.com/59513204/119358602-273b5380-bc7f-11eb-84bd-5a6e7146657e.png">




[Contributor Dashboard]  Improve status of translations progression
**Problem description**
Translators, while translating a chapter, would like to know how many translations are still missing.

**Solution description**
It may be a good idea to show on the translation cards how many cards are still missing, as well as showing on the progress bar how many translations are waiting for revisions.

Missing translations: 
In the suggestion image below, the numbers of missing translations was placed next to the chapter name to be easily related to it.
![cards left](https://user-images.githubusercontent.com/59513204/119356006-4dabbf80-bc7c-11eb-920a-f6bb0ff04b96.png)

Progress bar: 
In the suggestion image below, we have a new color (light green) to represent the status of the translations awaiting review, and tooltips to legend what color represents what (accepted, waiting reviews and untranslated).
![progress bar](https://user-images.githubusercontent.com/59513204/119356330-b004c000-bc7c-11eb-960e-b79161735db2.png)




**Other alternatives considered**

<img width="811" alt="Captura de Tela 2021-05-24 às 10 56 54" src="https://user-images.githubusercontent.com/59513204/119358540-1854a100-bc7f-11eb-95c5-94d13c891c4f.png">

<img width="586" alt="Captura de Tela 2021-05-24 às 10 58 26" src="https://user-images.githubusercontent.com/59513204/119358593-230f3600-bc7f-11eb-9107-8dc2cc322b85.png">

<img width="586" alt="Captura de Tela 2021-05-24 às 10 58 34" src="https://user-images.githubusercontent.com/59513204/119358602-273b5380-bc7f-11eb-84bd-5a6e7146657e.png">






Discussion:
+1

It would be useful to know what % of issues are awaiting review - this helps contributors regulate and plan the rate at which they would like/need to add translations.

As mentioned above, a light-green or yellow section (in addition to the existing green section) in the progress bar would be incredibly helpful:
![update_progress_bar_to_show_fraction_of_items_in_review](https://user-images.githubusercontent.com/62020692/121238795-63320380-c890-11eb-964e-39016c00f727.png)

+1

It would be useful to know what % of issues are awaiting review - this helps contributors regulate and plan the rate at which they would like/need to add translations.

As mentioned above, a light-green or yellow section (in addition to the existing green section) in the progress bar would be incredibly helpful:
![update_progress_bar_to_show_fraction_of_items_in_review](https://user-images.githubusercontent.com/62020692/121238795-63320380-c890-11eb-964e-39016c00f727.png)


Resolving patch:
diff --git a/core/domain/opportunity_domain.py b/core/domain/opportunity_domain.py
--- a/core/domain/opportunity_domain.py
+++ b/core/domain/opportunity_domain.py
@@ -33,7 +33,8 @@ def __init__(
             self, exp_id, topic_id, topic_name, story_id, story_title,
             chapter_title, content_count, incomplete_translation_language_codes,
             translation_counts, language_codes_needing_voice_artists,
-            language_codes_with_assigned_voice_artists):
+            language_codes_with_assigned_voice_artists,
+            translation_in_review_counts):
         """Constructs a ExplorationOpportunitySummary domain object.
 
         Args:
@@ -54,6 +55,9 @@ def __init__(
             language_codes_with_assigned_voice_artists: list(str). A list of
                 language code for which a voice-artist is already assigned to
                 the exploration.
+            translation_in_review_counts: dict. A dict with language code as a
+                key and number of translation in review in that language as the
+                value.
         """
         self.id = exp_id
         self.topic_id = topic_id
@@ -69,6 +73,7 @@ def __init__(
             language_codes_needing_voice_artists)
         self.language_codes_with_assigned_voice_artists = (
             language_codes_with_assigned_voice_artists)
+        self.translation_in_review_counts = translation_in_review_counts
         self.validate()
 
     @classmethod
@@ -97,7 +102,9 @@ def from_dict(cls, exploration_opportunity_summary_dict):
             exploration_opportunity_summary_dict[
                 'language_codes_needing_voice_artists'],
             exploration_opportunity_summary_dict[
-                'language_codes_with_assigned_voice_artists'])
+                'language_codes_with_assigned_voice_artists'],
+            exploration_opportunity_summary_dict[
+                'translation_in_review_counts'])
 
     def to_dict(self):
         """Return a copy of the object as a dictionary. It includes all
@@ -117,7 +124,8 @@ def to_dict(self):
             'story_title': self.story_title,
             'chapter_title': self.chapter_title,
             'content_count': self.content_count,
-            'translation_counts': self.translation_counts
+            'translation_counts': self.translation_counts,
+            'translation_in_review_counts': self.translation_in_review_counts
         }
 
     def validate(self):
@@ -164,25 +172,9 @@ def validate(self):
                 'languages to be disjoint, received: %s, %s' % (
                     self.language_codes_needing_voice_artists,
                     self.language_codes_with_assigned_voice_artists))
-        for language_code, count in (
-                self.translation_counts.items()):
-            if not utils.is_supported_audio_language_code(language_code):
-                raise utils.ValidationError(
-                    'Invalid language_code: %s' % language_code)
-            if not isinstance(count, int):
-                raise utils.ValidationError(
-                    'Expected count for language_code %s to be an integer, '
-                    'received %s' % (language_code, count))
-            if count < 0:
-                raise utils.ValidationError(
-                    'Expected count for language_code %s to be a non-negative '
-                    'integer, received %s' % (language_code, count))
 
-            if count > self.content_count:
-                raise utils.ValidationError(
-                    'Expected translation count for language_code %s to be '
-                    'less than or equal to content_count(%s), received %s' % (
-                        language_code, self.content_count, count))
+        self._validate_translation_counts(self.translation_counts)
+        self._validate_translation_counts(self.translation_in_review_counts)
 
         expected_set_of_all_languages = set(
             self.incomplete_translation_language_codes +
@@ -201,6 +193,36 @@ def validate(self):
                 ' to be the same as the supported audio languages, '
                 'received %s' % list(sorted(expected_set_of_all_languages)))
 
+    def _validate_translation_counts(self, translation_counts):
+        """Validates per-language counts of translations.
+
+        Args:
+            translation_counts: dict. A dict with language code as a key and
+                number of translations in that language as the value.
+
+        Raises:
+            ValidationError. One or more attributes of the object are invalid.
+        """
+        for language_code, count in (
+                translation_counts.items()):
+            if not utils.is_supported_audio_language_code(language_code):
+                raise utils.ValidationError(
+                    'Invalid language_code: %s' % language_code)
+            if not isinstance(count, int):
+                raise utils.ValidationError(
+                    'Expected count for language_code %s to be an integer, '
+                    'received %s' % (language_code, count))
+            if count < 0:
+                raise utils.ValidationError(
+                    'Expected count for language_code %s to be a non-negative '
+                    'integer, received %s' % (language_code, count))
+
+            if count > self.content_count:
+                raise utils.ValidationError(
+                    'Expected translation count for language_code %s to be '
+                    'less than or equal to content_count(%s), received %s' % (
+                        language_code, self.content_count, count))
+
 
 class SkillOpportunity(python_utils.OBJECT):
     """The domain object for skill opportunities."""
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
@@ -29,8 +29,8 @@
 from core.domain import topic_fetchers
 from core.platform import models
 
-(opportunity_models,) = models.Registry.import_models(
-    [models.NAMES.opportunity])
+(opportunity_models, suggestion_models) = models.Registry.import_models(
+    [models.NAMES.opportunity, models.NAMES.suggestion])
 
 # NOTE TO DEVELOPERS: The functions:
 #   - delete_all_exploration_opportunity_summary_models()
@@ -90,7 +90,45 @@ def get_exploration_opportunity_summary_from_model(model):
         model.story_title, model.chapter_title, model.content_count,
         new_incomplete_translation_language_codes, model.translation_counts,
         model.language_codes_needing_voice_artists,
-        model.language_codes_with_assigned_voice_artists)
+        model.language_codes_with_assigned_voice_artists,
+        {})
+
+
+def get_exp_opportunity_summary_with_in_review_translations_from_model(
+    model, translations_in_review):
+    """Returns the ExplorationOpportunitySummary object out of the model when
+    there are translations that are in review.
+
+    Args:
+        model: ExplorationOpportunitySummaryModel. The exploration opportunity
+            summary model.
+        translations_in_review: list(SuggestionModel). The list of translations
+            which are in review.
+
+    Returns:
+        ExplorationOpportunitySummary. The corresponding
+        ExplorationOpportunitySummary object.
+    """
+    translation_opportunity = get_exploration_opportunity_summary_from_model(
+        model)
+    translation_in_review_counts = {}
+
+    for language_code in constants.SUPPORTED_CONTENT_LANGUAGES:
+        in_review_count = 0
+        for suggestion in translations_in_review:
+            if (
+                suggestion is not None and
+                suggestion.language_code == language_code['code'] and
+                suggestion.target_id == model.id):
+                in_review_count = in_review_count + 1
+        if in_review_count > 0:
+            translation_in_review_counts[
+                language_code['code']] = in_review_count
+
+    translation_opportunity.translation_in_review_counts = (
+        translation_in_review_counts)
+
+    return translation_opportunity
 
 
 def _save_multi_exploration_opportunity_summary(
@@ -178,7 +216,8 @@ def _create_exploration_opportunity_summary(topic, story, exploration):
             exploration.id, topic.id, topic.name, story.id, story.title,
             story_node.title, content_count,
             incomplete_translation_language_codes,
-            translation_counts, list(language_codes_needing_voice_artists), []))
+            translation_counts, list(language_codes_needing_voice_artists), [],
+            {}))
 
     return exploration_opportunity_summary
 
@@ -457,10 +496,25 @@ def get_translation_opportunities(language_code, cursor):
         .ExplorationOpportunitySummaryModel.get_all_translation_opportunities(
             page_size, cursor, language_code))
     opportunities = []
+    suggestion_ids = []
+    opportunity_ids = []
+    translations_in_review = []
+    opportunity_ids = [
+        opportunity.id for opportunity in exp_opportunity_summary_models]
+    if len(opportunity_ids) > 0:
+        suggestion_ids = (
+            suggestion_models
+            .GeneralSuggestionModel
+            .get_translation_suggestions_in_review_ids_with_exp_id(
+                opportunity_ids))
+        translations_in_review = (
+            suggestion_models
+            .GeneralSuggestionModel
+            .get_multiple_suggestions_from_suggestion_ids(suggestion_ids))
     for exp_opportunity_summary_model in exp_opportunity_summary_models:
         exp_opportunity_summary = (
-            get_exploration_opportunity_summary_from_model(
-                exp_opportunity_summary_model))
+            get_exp_opportunity_summary_with_in_review_translations_from_model(
+                exp_opportunity_summary_model, translations_in_review))
         opportunities.append(exp_opportunity_summary)
     return opportunities, cursor, more
 
diff --git a/core/storage/suggestion/gae_models.py b/core/storage/suggestion/gae_models.py
--- a/core/storage/suggestion/gae_models.py
+++ b/core/storage/suggestion/gae_models.py
@@ -313,6 +313,44 @@ def get_translation_suggestions_in_review_with_exp_id(
             cls.target_id == exp_id
         )).fetch(feconf.DEFAULT_QUERY_LIMIT)
 
+    @classmethod
+    def get_multiple_suggestions_from_suggestion_ids(
+            cls, suggestion_ids: List[str]
+    ) -> List[Optional['GeneralSuggestionModel']]:
+        """Returns suggestions matching the supplied suggestion IDs.
+
+        Args:
+            suggestion_ids: list(str). Suggestion IDs of suggestions that need
+                to be returned.
+
+        Returns:
+            list(SuggestionModel|None). A list of suggestions in matching the
+            supplied suggestion IDs.
+        """
+        return GeneralSuggestionModel.get_multi(suggestion_ids)
+
+    @classmethod
+    def get_translation_suggestions_in_review_ids_with_exp_id(
+            cls, target_exp_ids: List[str]
+    ) -> List[str]:
+        """Returns IDs of in review translation suggestions matching the
+        supplied target IDs.
+
+        Args:
+            target_exp_ids: list(str). Exploration IDs matching the target ID
+                of the translation suggestions.
+
+        Returns:
+            list(str). A list of IDs of translation suggestions in review
+            with given target_exp_ids.
+        """
+        suggestion_keys = GeneralSuggestionModel.query(
+            cls.status == STATUS_IN_REVIEW,
+            GeneralSuggestionModel.target_id.IN(target_exp_ids)
+            ).fetch(keys_only=True)
+
+        return [suggestion_key.id() for suggestion_key in suggestion_keys]
+
     @classmethod
     def get_translation_suggestion_ids_with_exp_ids(
             cls, exp_ids: List[str]


Test patch:
diff --git a/core/controllers/contributor_dashboard_test.py b/core/controllers/contributor_dashboard_test.py
--- a/core/controllers/contributor_dashboard_test.py
+++ b/core/controllers/contributor_dashboard_test.py
@@ -149,7 +149,8 @@ def setUp(self):
             'story_title': 'title 0',
             'chapter_title': 'Node1',
             'content_count': 2,
-            'translation_counts': {}
+            'translation_counts': {},
+            'translation_in_review_counts': {}
         }
 
         self.expected_opportunity_dict_2 = {
@@ -158,7 +159,8 @@ def setUp(self):
             'story_title': 'title 1',
             'chapter_title': 'Node1',
             'content_count': 2,
-            'translation_counts': {}
+            'translation_counts': {},
+            'translation_in_review_counts': {}
         }
         config_services.set_property(
             'admin', 'contributor_dashboard_is_enabled', True)
diff --git a/core/domain/opportunity_domain_test.py b/core/domain/opportunity_domain_test.py
--- a/core/domain/opportunity_domain_test.py
+++ b/core/domain/opportunity_domain_test.py
@@ -56,7 +56,8 @@ def setUp(self):
                     'incomplete_translation_language_codes': ['hi-en'],
                     'translation_counts': {},
                     'language_codes_needing_voice_artists': ['en'],
-                    'language_codes_with_assigned_voice_artists': ['hi']
+                    'language_codes_with_assigned_voice_artists': ['hi'],
+                    'translation_in_review_counts': {}
                 }))
         # Re-initializing this swap, so that we can use this in test method.
         self.mock_supported_audio_languages_context = self.swap(
@@ -75,7 +76,8 @@ def test_to_and_from_dict_works_correctly(self):
             'incomplete_translation_language_codes': ['hi-en', 'hi'],
             'translation_counts': {},
             'language_codes_needing_voice_artists': ['en'],
-            'language_codes_with_assigned_voice_artists': []
+            'language_codes_with_assigned_voice_artists': [],
+            'translation_in_review_counts': {}
         }
 
         with self.mock_supported_audio_languages_context:
@@ -91,6 +93,7 @@ def test_to_and_from_dict_works_correctly(self):
             'chapter_title': 'A new chapter',
             'content_count': 5,
             'translation_counts': {},
+            'translation_in_review_counts': {}
         })
 
     def test_invalid_topic_id_fails_validation_check(self):
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
@@ -185,6 +185,38 @@ def test_new_opportunity_with_adding_exploration_in_story_node(self):
         self.assertEqual(opportunity.topic_name, 'topic')
         self.assertEqual(opportunity.story_title, 'A story')
 
+    def test_get_translation_opportunities_with_translations_in_review(
+        self):
+        translation_opportunities, _, _ = (
+            opportunity_services.get_translation_opportunities('hi', None))
+        self.assertEqual(len(translation_opportunities), 0)
+
+        self.add_exploration_0_to_story()
+        self.create_translation_suggestion_for_exploration_0_and_verify()
+
+        translation_opportunities, _, _ = (
+            opportunity_services.get_translation_opportunities('hi', None))
+        self.assertEqual(len(translation_opportunities), 1)
+        opportunity = translation_opportunities[0]
+        languages_of_translations_in_review = (
+            opportunity.translation_in_review_counts.keys())
+        self.assertEqual(len(languages_of_translations_in_review), 1)
+
+    def test_get_translation_opportunities_with_no_translations_in_review(self):
+        translation_opportunities, _, _ = (
+            opportunity_services.get_translation_opportunities('hi', None))
+        self.assertEqual(len(translation_opportunities), 0)
+
+        self.add_exploration_0_to_story()
+
+        translation_opportunities, _, _ = (
+            opportunity_services.get_translation_opportunities('hi', None))
+        self.assertEqual(len(translation_opportunities), 1)
+        opportunity = translation_opportunities[0]
+        languages_of_translations_in_review = (
+            opportunity.translation_in_review_counts.keys())
+        self.assertEqual(len(languages_of_translations_in_review), 0)
+
     def test_opportunity_get_deleted_with_removing_exploration_from_story_node(
             self):
         self.add_exploration_0_to_story()
diff --git a/core/storage/suggestion/gae_models_test.py b/core/storage/suggestion/gae_models_test.py
--- a/core/storage/suggestion/gae_models_test.py
+++ b/core/storage/suggestion/gae_models_test.py
@@ -330,6 +330,55 @@ def test_query_suggestions_by_language(self) -> None:
             len(suggestion_models.GeneralSuggestionModel.query_suggestions(
                 queries)), 1)
 
+    def test_get_translation_suggestions_in_review_ids_with_valid_exp(
+        self) -> None:
+        suggestion_models.GeneralSuggestionModel.create(
+            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            feconf.ENTITY_TYPE_EXPLORATION,
+            'exp1', self.target_version_at_submission,
+            suggestion_models.STATUS_IN_REVIEW, 'author_3',
+            'reviewer_2', self.change_cmd, self.score_category,
+            'exploration.exp1.thread_6', self.translation_language_code)
+        suggestion_models.GeneralSuggestionModel.create(
+            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            feconf.ENTITY_TYPE_EXPLORATION,
+            'exp1', self.target_version_at_submission,
+            suggestion_models.STATUS_IN_REVIEW, 'author_4',
+            'reviewer_2', self.change_cmd, self.score_category,
+            'exploration.exp1.thread_7', self.translation_language_code)
+
+        suggestion_ids = (
+            suggestion_models.GeneralSuggestionModel
+            .get_translation_suggestions_in_review_ids_with_exp_id(
+                ['exp1']))
+
+        self.assertEqual(len(suggestion_ids), 3)
+
+    def test_get_multiple_translation_suggestions_in_review(self) -> None:
+        suggestion_models.GeneralSuggestionModel.create(
+            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            feconf.ENTITY_TYPE_EXPLORATION,
+            'exp1', self.target_version_at_submission,
+            suggestion_models.STATUS_IN_REVIEW, 'author_3',
+            'reviewer_2', self.change_cmd, self.score_category,
+            'exploration.exp1.thread_6', self.translation_language_code)
+        suggestion_models.GeneralSuggestionModel.create(
+            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            feconf.ENTITY_TYPE_EXPLORATION,
+            'exp1', self.target_version_at_submission,
+            suggestion_models.STATUS_IN_REVIEW, 'author_4',
+            'reviewer_2', self.change_cmd, self.score_category,
+            'exploration.exp1.thread_7', self.translation_language_code)
+
+        suggestion_ids = (
+            suggestion_models.GeneralSuggestionModel
+            .get_translation_suggestions_in_review_ids_with_exp_id(
+                ['exp1']))
+        suggestions = (
+            suggestion_models.GeneralSuggestionModel
+            .get_multiple_suggestions_from_suggestion_ids(suggestion_ids))
+        self.assertEqual(len(suggestions), 3)
+
     def test_get_translation_suggestions_in_review_with_valid_exp(self) -> None:
         suggestion_models.GeneralSuggestionModel.create(
             feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,