Repository: oppia/oppia

Issue:
Some contributors can not generate the certificate
Some of the contributors are not able to generate the certificate possibly due the following error. This should be investigated and fixed.

```
TypeError 
expected string or bytes-like object 
Exception raised: expected string or bytes-like object Traceback (most recent call last): File "/layers/google.python.pip/pip/lib/python3.8/site-packages/webapp2.py", line 604, in dispatch return method(*args, **kwargs) File "/workspace/core/controllers/acl_decorators.py", line 4788, in test_can_fetch_all_contributor_dashboard_stats return handler(self, username, **kwargs) File "/workspace/core/controllers/contributor_dashboard.py", line 1062, in get response = suggestion_services.generate_contributor_certificate_data( File "/workspace/core/domain/suggestion_services.py", line 3870, in generate_contributor_certificate_data data = _generate_translation_contributor_certificate_data( File "/workspace/core/domain/suggestion_services.py", line 3945, in _generate_translation_contributor_certificate_data plain_text = _get_plain_text_from_html_content_string( File "/workspace/core/domain/suggestion_services.py", line 1408, in _get_plain_text_from_html_content_string html_content_string_with_rte_tags_replaced = re.sub( File "/layers/google.python.runtime/python/lib/python3.8/re.py", line 210, in sub return _compile(pattern, flags).sub(repl, string, count) TypeError: expected string or bytes-like object
```


Discussion:

Resolving patch:
diff --git a/core/domain/suggestion_services.py b/core/domain/suggestion_services.py
--- a/core/domain/suggestion_services.py
+++ b/core/domain/suggestion_services.py
@@ -37,6 +37,7 @@
 from core.domain import state_domain
 from core.domain import suggestion_registry
 from core.domain import taskqueue_services
+from core.domain import translation_domain
 from core.domain import user_domain
 from core.domain import user_services
 from core.platform import models
@@ -3964,22 +3965,34 @@ def _generate_translation_contributor_certificate_data(
     words_count = 0
     for model in suggestions:
         suggestion = get_suggestion_from_model(model)
-
-        # Retrieve the html content that is emphasized on the
-        # Contributor Dashboard pages. This content is what stands
-        # out for each suggestion when a user views a list of
-        # suggestions.
-        get_html_representing_suggestion = (
-            SUGGESTION_EMPHASIZED_TEXT_GETTER_FUNCTIONS[
-                suggestion.suggestion_type]
+        suggestion_change = suggestion.change_cmd
+        data_is_list = (
+            translation_domain.TranslatableContentFormat
+            .is_data_format_list(suggestion_change.data_format)
         )
-        plain_text = _get_plain_text_from_html_content_string(
-            get_html_representing_suggestion(suggestion))
+        if (
+                suggestion_change.cmd == 'add_written_translation' and
+                data_is_list
+        ):
+            words_count += sum(
+                len(item.split()) for item in suggestion_change.translation_html
+            )
+        else:
+            # Retrieve the html content that is emphasized on the
+            # Contributor Dashboard pages. This content is what stands
+            # out for each suggestion when a user views a list of
+            # suggestions.
+            get_html_representing_suggestion = (
+                SUGGESTION_EMPHASIZED_TEXT_GETTER_FUNCTIONS[
+                    suggestion.suggestion_type]
+            )
+            plain_text = _get_plain_text_from_html_content_string(
+                get_html_representing_suggestion(suggestion))
 
-        words = plain_text.split(' ')
-        words_without_empty_strings = [
-            word for word in words if word != '']
-        words_count += len(words_without_empty_strings)
+            words = plain_text.split(' ')
+            words_without_empty_strings = [
+                word for word in words if word != '']
+            words_count += len(words_without_empty_strings)
     # Go to the below link for more information about how we count hours
     # contributed.# Goto the below link for more information.
     # https://docs.google.com/spreadsheets/d/1ykSNwPLZ5qTCkuO21VLdtm_2SjJ5QJ0z0PlVjjSB4ZQ/edit?usp=sharing


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
@@ -2172,12 +2172,13 @@ def test_get_contributor_certificate(self) -> None:
             suggestion_models.SCORE_TYPE_TRANSLATION +
             suggestion_models.SCORE_CATEGORY_DELIMITER + 'English')
         change_cmd = {
-            'cmd': 'add_translation',
+            'cmd': 'add_written_translation',
             'content_id': 'content',
             'language_code': 'hi',
             'content_html': '',
             'state_name': 'Introduction',
-            'translation_html': '<p>Translation for content.</p>'
+            'translation_html': '<p>Translation for content.</p>',
+            'data_format': 'html'
         }
         suggestion_models.GeneralSuggestionModel.create(
             feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
diff --git a/core/domain/suggestion_services_test.py b/core/domain/suggestion_services_test.py
--- a/core/domain/suggestion_services_test.py
+++ b/core/domain/suggestion_services_test.py
@@ -7258,17 +7258,75 @@ def setUp(self) -> None:
         self.from_date = datetime.datetime.today() - datetime.timedelta(days=1)
         self.to_date = datetime.datetime.today() + datetime.timedelta(days=1)
 
+    def _get_change_with_normalized_string(self) -> Mapping[
+        str, change_domain.AcceptableChangeDictTypes]:
+        """Provides change_cmd dictionary with normalized translation html.
+
+        Returns:
+            Mapping[str, change_domain.AcceptableChangeDictTypes]. A dictionary
+            of the change_cmd object for the translations.
+        """
+        return {
+            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
+            'content_id': 'content_0',
+            'language_code': 'hi',
+            'content_html': '<p>A content to translate.</p>',
+            'state_name': 'Introduction',
+            'translation_html': ['translated text1', 'translated text2'],
+            'data_format': 'set_of_normalized_string'
+        }
+
+    def _calculate_translation_contribution_hours(
+        self, numer_of_words: int
+    ) -> str:
+        """Provides translatoin contribution hours when number of translated
+        words are provided. We calculate the time taken to translate
+        a word according to the following document.
+        https://docs.google.com/spreadsheets/d/1ykSNwPLZ5qTCkuO21VLdtm_2SjJ5QJ0z0PlVjjSB4ZQ/edit#gid=0
+
+        Args:
+            numer_of_words: int. The number of translated words.
+
+        Returns:
+            str. A string that represent the translatoin contribution hours.
+        """
+        return str(round(numer_of_words / 300, 2))
+
+    def _calculate_question_contribution_hours(
+        self, images_included: bool
+    ) -> str:
+        """Provides question contribution hours when number of questions
+        are provided. We calculate the time taken to submit
+        a question according to the following document.
+        https://docs.google.com/spreadsheets/d/1ykSNwPLZ5qTCkuO21VLdtm_2SjJ5QJ0z0PlVjjSB4ZQ/edit#gid=0
+
+        Args:
+            images_included: bool. A flag that says whether the question
+                contains images.
+
+        Returns:
+            str. A string that represent the question contribution hours.
+        """
+        minutes_contributed = 0
+
+        if images_included:
+            minutes_contributed += 20
+        else:
+            minutes_contributed += 12
+        return str(round(minutes_contributed / 60, 2))
+
     def test_create_translation_contributor_certificate(self) -> None:
         score_category: str = (
             suggestion_models.SCORE_TYPE_TRANSLATION +
             suggestion_models.SCORE_CATEGORY_DELIMITER + 'English')
         change_cmd = {
-            'cmd': 'add_translation',
+            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
             'content_id': 'content',
             'language_code': 'hi',
             'content_html': '',
             'state_name': 'Introduction',
-            'translation_html': '<p>Translation for content.</p>'
+            'translation_html': '<p>Translation for content.</p>',
+            'data_format': 'html'
         }
         suggestion_models.GeneralSuggestionModel.create(
             feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
@@ -7286,6 +7344,40 @@ def test_create_translation_contributor_certificate(self) -> None:
         )
 
         self.assertIsNotNone(response)
+        self.assertEqual(
+            response['contribution_hours'],
+            self._calculate_translation_contribution_hours(3)
+        )
+        self.assertEqual(response['language'], 'Hindi')
+
+    def test_create_translation_contributor_certificate_for_rule_translation(
+        self
+    ) -> None:
+        score_category: str = (
+            suggestion_models.SCORE_TYPE_TRANSLATION +
+            suggestion_models.SCORE_CATEGORY_DELIMITER + 'English')
+        change_cmd = self._get_change_with_normalized_string()
+        suggestion_models.GeneralSuggestionModel.create(
+            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            feconf.ENTITY_TYPE_EXPLORATION,
+            'exp1', 1, suggestion_models.STATUS_ACCEPTED, self.author_id,
+            'reviewer_1', change_cmd, score_category,
+            'exploration.exp1.thread_6', 'hi')
+
+        response = suggestion_services.generate_contributor_certificate_data(
+            self.username,
+            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            'hi',
+            self.from_date,
+            self.to_date,
+        )
+
+        self.assertIsNotNone(response)
+        self.assertEqual(
+            response['contribution_hours'],
+            self._calculate_translation_contribution_hours(4)
+        )
+        self.assertEqual(response['language'], 'Hindi')
 
     def test_create_translation_contributor_certificate_for_english(
         self
@@ -7294,12 +7386,13 @@ def test_create_translation_contributor_certificate_for_english(
             suggestion_models.SCORE_TYPE_TRANSLATION +
             suggestion_models.SCORE_CATEGORY_DELIMITER + 'English')
         change_cmd = {
-            'cmd': 'add_translation',
+            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
             'content_id': 'content',
             'language_code': 'en',
             'content_html': '',
             'state_name': 'Introduction',
-            'translation_html': '<p>Translation for content.</p>'
+            'translation_html': '<p>Translation for content.</p>',
+            'data_format': 'html'
         }
         suggestion_models.GeneralSuggestionModel.create(
             feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
@@ -7317,6 +7410,11 @@ def test_create_translation_contributor_certificate_for_english(
         )
 
         self.assertIsNotNone(response)
+        self.assertEqual(
+            response['contribution_hours'],
+            self._calculate_translation_contribution_hours(3)
+        )
+        self.assertEqual(response['language'], 'English')
 
     def test_create_question_contributor_certificate(self) -> None:
         content_id_generator = translation_domain.ContentIdGenerator()
@@ -7367,6 +7465,10 @@ def test_create_question_contributor_certificate(self) -> None:
         )
 
         self.assertIsNotNone(response)
+        self.assertEqual(
+            response['contribution_hours'],
+            self._calculate_question_contribution_hours(False)
+        )
 
     def test_create_question_contributor_certificate_with_image_content(
         self
@@ -7420,6 +7522,10 @@ def test_create_question_contributor_certificate_with_image_content(
         )
 
         self.assertIsNotNone(response)
+        self.assertEqual(
+            response['contribution_hours'],
+            self._calculate_question_contribution_hours(True)
+        )
 
     def test_create_contributor_certificate_raises_exception_for_no_suggestions(
         self