Repository: oppia/oppia

Issue:
[Feature Request]: Add a function in the Admin activities page for generating some dummy blog posts.
### Is your feature request related to a problem? Please describe.

It's currently a bit tedious to fix issues related to blog pages on localhost because the blog post data needs to be manually created. The aim of this issue is to add a button to the Admin > Activities page that generates a bunch of random blog posts so that the populated blog page can be tested.

When generating, you might want to consider using titles, descriptions, etc. from Oppia's actual blog page: www.oppia.org/blog so that the data looks realistic. However, don't worry about making the content of the posts particularly long, as long as they include most of the usual elements (images etc.).

### Describe the solution (or solutions) you'd like

Add a generation function that can be triggerd by a button in the Activities tab in the /admin page for creating dummy blog posts for easy testing, similar to the other buttons that are already there.

### Describe alternatives you've considered and rejected

_No response_

### Additional context

See https://github.com/oppia/oppia/issues/19403#issuecomment-1880005809 for more context on why this is needed.


Discussion:
For new contributors: per the guidance at https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue, please provide an explanation of what your PR will do (with names of files you're changing, what you plan to change in each file, etc.). If it looks good, we can assign you to this issue. (Please also follow the other instructions on that wiki page if you have not yet done so. Thanks!)
> [www.oppia.com/blog](http://www.oppia.com/blog)

Hey @seanlip Ig it is
[www.oppia.org/blog](http://www.oppia.org/blog)


Sorry -- yup, updated, thanks!
Hi @seanlip,
I would like to take up this issue.


Frontend:
In `/core/templates/pages/admin-page/activities-tab/admin-dev-mode-activities-tab.component.html` add a new card for adding dummy blog. 
<img width="1499" alt="Screenshot 2024-01-09 at 11 09 20" src="https://github.com/oppia/oppia/assets/58803066/aff04856-399e-4cd4-b45b-df85ce437acc">

I believe this is to be done only in dev file only, not in prod file.

Backend:
In `admin.py` create a new method `generate_dummy_blog()` and add it to AdminHandler
<img width="734" alt="Screenshot 2024-01-09 at 11 07 27" src="https://github.com/oppia/oppia/assets/58803066/aa4be527-6ce8-4909-b789-825c7df6ba5a">

Question: Do we need to also include images in the blog body?

Resolving patch:
diff --git a/core/controllers/admin.py b/core/controllers/admin.py
--- a/core/controllers/admin.py
+++ b/core/controllers/admin.py
@@ -83,6 +83,72 @@
 supported_languages: List[str] = [
     lang['id'] for lang in constants.SUPPORTED_AUDIO_LANGUAGES]
 
+EDUCATION_BLOG_POST_TITLE = 'Education'
+
+EDUCATION_BLOG_POST_CONTENT = """
+    <p>
+        Education is a constantly evolving landscape, and innovation lies at its core. 
+        This summer, Oppia had the privilege of hosting a group of exceptional minds 
+        through the prestigious Google Summer of Code (GSoC) program. These talented 
+        individuals embarked on a journey to transform learning, one code at a time.
+    </p>\n
+    <p>
+        You should check out main 
+        <oppia-noninteractive-link 
+            text-with-value=\"&amp;quot;website.&amp;quot;\" 
+            url-with-value=\"&amp;quot;https://www.oppia.org&amp;quot;\">
+        </oppia-noninteractive-link><br>\n&nbsp;
+    </p>\n\n
+    <p>Introduction to Oppia - Youtube Video</p>
+    <oppia-noninteractive-video 
+        autoplay-with-value=\"false\" 
+        end-with-value=\"0\" 
+        start-with-value=\"0\" 
+        video_id-with-value=\"&amp;quot;Wmvt-HH5-dI&amp;quot;\">
+    </oppia-noninteractive-video>
+"""
+
+FORMATTING_BLOG_POST_TITLE = 'Blog with different font formatting'
+
+FORMATTING_BLOG_POST_CONTENT = """
+    <h1>Heading</h1>\n\n
+    <p>This is the normal text.</p>\n\n
+    <p><strong>Bold Text.</strong></p>\n\n
+    <p><em>Italic Text.</em></p>\n\n
+    <p><em><strong>Bold Italic.</strong></em></p>\n\n
+    Numbered List:</div>\n\n<ol>\n\t<li>List item&nbsp;</li>\n\t<li>List item\n\t<ol>\n\t\t<li>Sub list item</li>\n\t</ol>\n\t</li>\n</ol>\n\n
+    <p>Bullet List:</p>\n\n<ul>\n\t<li>List item</li>\n\t<li>List item\n\t<ul>\n\t\t<li>Sub list item</li>\n\t</ul>\n\t</li>\n</ul>\n\n
+    <pre>This is content in pre.</pre>\n\n
+    <blockquote>\n<p>Quote from some famous person. Two empty lines after this quote.</p>\n</blockquote>\n\n
+    <p>&nbsp;</p>\n\n<p>&nbsp;</p>\n\n<p>End of blog.</p>
+"""
+
+ARABIC_BLOG_POST_TITLE = 'Leading The Arabic Translations Team'
+
+ARABIC_BLOG_POST_CONTENT = """
+    <h1>Introduction:</h1>\n
+    <oppia-noninteractive-image alt-with-value=\"Oppia Arabic Blogpost Graphic\" caption-with-value=\"&amp;quot;&amp;quot;\" filepath-with-value=\"&amp;quot;blog_post_image_height_326_width_490.svg&amp;quot;\"></oppia-noninteractive-image>\n\n
+    <p><strong>Editor’s note:</strong> <em>The Arabic team at Oppia plays a pivotal role in breaking down language barriers and making educational content accessible to Arabic-speaking learners. One of the primary challenges lies in finding a suitable tone and language that can resonate with all Arabic-speaking regions, each of which has its unique dialects.</em></p>\n\n
+    <p><em>In this blog post, our team lead, Sarah, explains how teamwork can lead not only to professional accomplishments but also personal growth and lasting connections. The Arabic team's journey exemplifies the transformative power of collaboration, cultural exchange, and the profound impact of education on individuals and communities.</em></p>\n\n<p>Translating educational lessons presents a unique set of challenges, particularly when it comes to bridging language barriers and adapting content to suit diverse regional dialects. In this blog post, we will delve into the experiences of our dedicated team and explore how we tackled these obstacles head-on. We will also highlight the inspiring stories of past members, shedding light on their motivations and the invaluable skills they acquired through their contributions.</p>\n\n<p>&nbsp;</p>\n\n<h1>Finding the Right Tone and Language</h1>\n\n\n
+    <p>Contributing to this project offered more than just an opportunity to make a difference. For many of us, it served as a valuable stepping stone in our career paths. As an example, some of our team members were students, and through their involvement, they received practical experience and exposure to real-world challenges. This helped them develop essential skills such as teamwork, project management, and time management. We expect these acquired competencies to prove beneficial in their future professional endeavors.</p>\n\n
+    <h1>Conclusion</h1>\n\n
+    <p>Overcoming the challenges associated with translating lessons into Arabic required a dedicated and passionate team. By focusing on finding the right tone and language, our team members collaborated to ensure that educational content reached a wider audience across diverse Arabic-speaking regions. Through their contributions, they not only empowered learners but also experienced personal growth and acquired valuable skills. With this blog post, I would like to thank and celebrate the contributions of all the members of Oppia’s Arabic translation team. Our journey together stands as a testament to the power of teamwork, cultural exchange, and the positive impact of education on individuals and communities.</p>\n\n
+    <h1>Arabic hashtags:</h1>\n\n
+    <p>&nbsp;#تحديات_الترجمة</p>\n\n
+    <p>&nbsp;#حاجز_اللغة</p>\n\n
+    <p>&nbsp;#محتوى_تعليمي</p>\n\n
+    <p>&nbsp;#ترجمة_عربية</p>\n\n
+    <p>&nbsp;#تبادل_ثقافي</p>\n\n
+    <p>&nbsp;#العمل_الجماعي</p>\n\n
+    <p>&nbsp;#مسار_المهنة</p>\n\n
+    <p>&nbsp;#التعليم_مهم</p>\n\n
+    <h1>Credit:</h1>\n\n
+    <ul>\n\t
+    <li>This blogpost was written by Sarah Bendiff who currently leads Oppia's Arabic Translations Team.</li>\n\t
+    <li>Edits were done by all of the marketing team! (Thanks to the best teammates)</li>
+    </ul>
+"""
+
 
 class ClassroomPageDataDict(TypedDict):
     """Dict representation of classroom page's data dictionary."""
@@ -119,6 +185,7 @@ class AdminHandlerNormalizePayloadDict(TypedDict):
     commit_message: Optional[str]
     new_rules: Optional[List[parameter_domain.PlatformParameterRule]]
     exp_id: Optional[str]
+    blog_post_title: Optional[str]
     default_value: Dict[str, parameter_domain.PlatformDataTypes]
 
 
@@ -140,6 +207,7 @@ class AdminHandler(
                         'generate_dummy_explorations', 'clear_search_index',
                         'generate_dummy_new_structures_data',
                         'generate_dummy_new_skill_data',
+                        'generate_dummy_blog_post',
                         'generate_dummy_classroom',
                         'save_config_properties', 'revert_config_property',
                         'upload_topic_similarities',
@@ -225,6 +293,17 @@ class AdminHandler(
                 },
                 'default_value': None
             },
+            'blog_post_title': {
+                'schema': {
+                    'type': 'basestring',
+                    'choices': [
+                        ARABIC_BLOG_POST_TITLE,
+                        EDUCATION_BLOG_POST_TITLE,
+                        FORMATTING_BLOG_POST_TITLE,
+                    ]
+                },
+                'default_value': None
+            },
             'exp_id': {
                 'schema': {
                     'type': 'basestring'
@@ -359,6 +438,14 @@ def post(self) -> None:
 
                 self._generate_dummy_explorations(
                     num_dummy_exps_to_generate, num_dummy_exps_to_publish)
+            elif action == 'generate_dummy_blog_post':
+                blog_post_title = self.normalized_payload.get('blog_post_title')
+                if blog_post_title is None:
+                    raise Exception(
+                        'The \'blog_post_title\' must be provided when the'
+                        ' action is generate_dummy_blog_post.'
+                    )
+                self._load_dummy_blog_post(blog_post_title)
             elif action == 'clear_search_index':
                 search_services.clear_collection_search_index()
                 search_services.clear_exploration_search_index()
@@ -606,6 +693,73 @@ def _create_dummy_skill(
         skill.update_explanation(state_domain.SubtitledHtml('1', explanation))
         return skill
 
+    def _load_dummy_blog_post(self, blog_post_title: str) -> None:
+        """Loads the database with a blog post.
+
+        Raises:
+            Exception. Cannot load new blog post in production mode.
+        """
+        assert self.user_id is not None
+        if not constants.DEV_MODE:
+            raise Exception('Cannot load new blog post in production mode.')
+
+        blog_post = blog_services.create_new_blog_post(self.user_id)
+        fs = fs_services.GcsFileSystem('blog_post', blog_post.id)
+        with open(
+            './assets/images/general/learner1.png', 'rb'
+        ) as thumbnail:
+            fs.commit(
+                'thumbnail/blog_thumbnail.png',
+                thumbnail.read(),
+                'image/png'
+            )
+        with open(
+            './assets/images/subjects/Art.svg', 'rb'
+        ) as image:
+            fs.commit(
+                'image/blog_post_image_height_326_width_490.svg',
+                image.read(),
+                'image/svg+xml'
+            )
+
+        if blog_post_title == EDUCATION_BLOG_POST_TITLE:
+            blog_services.update_blog_post(blog_post.id, {
+                'title':
+                    '%s-%s' % (EDUCATION_BLOG_POST_TITLE, blog_post.id),
+                'thumbnail_filename': 'blog_thumbnail.png',
+                'content': EDUCATION_BLOG_POST_CONTENT,
+                'tags': ['Community']
+            })
+        elif blog_post_title == FORMATTING_BLOG_POST_TITLE:
+            blog_services.update_blog_post(blog_post.id, {
+                'title':
+                    '%s-%s' % (FORMATTING_BLOG_POST_TITLE, blog_post.id),
+                'content': FORMATTING_BLOG_POST_CONTENT,
+                'tags': ['Learners', 'Languages'],
+                'thumbnail_filename': 'blog_thumbnail.png'
+            })
+        else:
+            # The handler schema defines the possible values of
+            # 'blog_post_title'. If 'blog_post_title' has a value other than
+            # those defined in the schema, a Bad Request error will be thrown.
+            # Hence, 'blog_post_title' must be 'ARABIC_BLOG_POST_TITLE' if
+            # this branch is executed.
+            assert blog_post_title == ARABIC_BLOG_POST_TITLE
+            blog_services.update_blog_post(blog_post.id, {
+                'title':
+                    '%s-%s' % (ARABIC_BLOG_POST_TITLE, blog_post.id),
+                'content': ARABIC_BLOG_POST_CONTENT,
+                'tags': [
+                    'Learners',
+                    'Volunteer',
+                    'New features',
+                    'Community',
+                    'Languages'
+                ],
+                'thumbnail_filename': 'blog_thumbnail.png'
+            })
+        blog_services.publish_blog_post(blog_post.id)
+
     def _load_dummy_new_structures_data(self) -> None:
         """Loads the database with two topics (one of which is empty), a story
         and three skills in the topic (two of them in a subtopic) and a question


Test patch:
diff --git a/core/controllers/admin_test.py b/core/controllers/admin_test.py
--- a/core/controllers/admin_test.py
+++ b/core/controllers/admin_test.py
@@ -192,6 +192,26 @@ def test_without_collection_id_reload_collection_action_is_not_performed(
 
         self.logout()
 
+    def test_without_blog_post_title_generate_dummy_blog_post_is_not_performed(
+        self
+    ) -> None:
+        self.set_curriculum_admins([self.CURRICULUM_ADMIN_USERNAME])
+        self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True)
+        csrf_token = self.get_new_csrf_token()
+
+        assert_raises_regexp_context_manager = self.assertRaisesRegex(
+            Exception,
+            'The \'blog_post_title\' must be provided when the action '
+            'is generate_dummy_blog_post.'
+        )
+        with assert_raises_regexp_context_manager, self.prod_mode_swap:
+            self.post_json(
+                '/adminhandler', {
+                    'action': 'generate_dummy_blog_post',
+                    'blog_post_title': None
+                }, csrf_token=csrf_token)
+        self.logout()
+
     def test_without_num_dummy_exps_generate_dummy_exp_action_is_not_performed(
         self
     ) -> None:
@@ -3032,3 +3052,88 @@ def test_update_blog_post_with_correct_params(self) -> None:
                 'published_on': '05/09/2000'
             },
             csrf_token=csrf_token)
+
+
+class GenerateDummyBlogPostTest(test_utils.GenericTestBase):
+    """Tests the generation of dummy blog post data."""
+
+    def setUp(self) -> None:
+        super().setUp()
+        self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME)
+
+    def test_cannot_generate_dummy_blog_post_in_prod_mode(self) -> None:
+        self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True)
+        csrf_token = self.get_new_csrf_token()
+
+        prod_mode_swap = self.swap(constants, 'DEV_MODE', False)
+        assert_raises_regexp_context_manager = self.assertRaisesRegex(
+            Exception, 'Cannot load new blog post in production mode.')
+
+        with assert_raises_regexp_context_manager, prod_mode_swap:
+            self.post_json(
+                '/adminhandler', {
+                    'action': 'generate_dummy_blog_post',
+                    'blog_post_title': 'Education',
+                }, csrf_token=csrf_token)
+
+        blog_post_count = (
+            blog_services.get_total_number_of_published_blog_post_summaries()
+        )
+        self.assertEqual(blog_post_count, 0)
+        self.logout()
+
+    def test_generate_dummy_blog_post(self) -> None:
+        self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True)
+        csrf_token = self.get_new_csrf_token()
+        self.post_json(
+            '/adminhandler', {
+                'action': 'generate_dummy_blog_post',
+                'blog_post_title': 'Education',
+            }, csrf_token=csrf_token)
+        blog_post_count = (
+            blog_services.get_total_number_of_published_blog_post_summaries()
+        )
+        self.assertEqual(blog_post_count, 1)
+
+        self.post_json(
+            '/adminhandler', {
+                'action': 'generate_dummy_blog_post',
+                'blog_post_title': 'Blog with different font formatting',
+            }, csrf_token=csrf_token)
+        blog_post_count = (
+            blog_services.get_total_number_of_published_blog_post_summaries()
+        )
+        self.assertEqual(blog_post_count, 2)
+
+        self.post_json(
+            '/adminhandler', {
+                'action': 'generate_dummy_blog_post',
+                'blog_post_title': 'Leading The Arabic Translations Team',
+            }, csrf_token=csrf_token)
+        blog_post_count = (
+            blog_services.get_total_number_of_published_blog_post_summaries()
+        )
+        self.assertEqual(blog_post_count, 3)
+        self.logout()
+
+    def test_handler_raises_error_with_invalid_blog_post_title(self) -> None:
+        self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True)
+        csrf_token = self.get_new_csrf_token()
+        invalid_blog_post_title = 'blog_title'
+        response = self.post_json(
+            '/adminhandler', {
+                'action': 'generate_dummy_blog_post',
+                'blog_post_title': invalid_blog_post_title,
+            }, csrf_token=csrf_token, expected_status_int=400)
+
+        error_msg = (
+            'Schema validation for \'blog_post_title\' failed: Received %s '
+            'which is not in the allowed range of choices: [\'Leading The '
+            'Arabic Translations Team\', \'Education\', \'Blog with different'
+            ' font formatting\']' % invalid_blog_post_title) 
+        self.assertEqual(response['error'], error_msg)
+        blog_post_count = (
+            blog_services.get_total_number_of_published_blog_post_summaries()
+        )
+        self.assertEqual(blog_post_count, 0)
+        self.logout()