Repository: oppia/oppia
Pull request #22453: Fix Issue #20463: Adds Warning Message for Invalid Activity IDs entered on Moderator Page Featured Activities Tab
Author: login: charliemax5
Labels: name: PR: LGTM
Created: 2025-04-14T01:43:48Z | Merged: 2025-05-24T07:10:28Z

## Overview

1. This PR fixes or fixes part of #20463.
2. This PR does the following:
    - `moderator.py` catches POST requests from `moderator-page.component.ts` and checks the IDs of Activities in payload by calling `check_activity_id_validity()` function in `summary_services.py` to compile lists of Activity IDs corresponding to each type of Invalid ID: nonexistent Exploration, nonexistent Collection, private Exploration, and private Collection.
    -  `summary_services.py` `check_activity_id_validity()` requests Activity summaries from backend, and compiles the IDs that do not have a summary or have a summary set to private into lists and returns that to `moderator.py`.
    - `moderator.py` receives lists of Invalid IDs and creates a custom error message based upon which Invalid IDs are received and return that as a 400 error to `moderator-page.component.ts`.
    - `moderator-page.component.ts` catches the 400 error and passes the error message to alertsService, which displays the message telling the User which IDs are invalid and why they are Invalid.
4. (For bug-fixing PRs only) The original bug occurred because: 
    - Previously, summary_service.py only raises an Exception when an Invalid ID is entered, while moderator.py and moderator-page.component.ts do nothing when an Invalid ID is entered and the Exception is raised.

## Essential Checklist

Please follow the [instructions for making a code change](https://github.com/oppia/oppia/wiki/Make-a-pull-request).

- [x] I have linked the issue that this PR fixes in the "Development" section of the sidebar.
- [x] I have checked the "Files Changed" tab and confirmed that the changes are what I want to make.
- [x] I have written tests for my code.
- [x] The **PR title** starts with "Fix #bugnum: " or "Fix part of #bugnum: ...", followed by a short, clear summary of the changes.
- [x] I have assigned the correct reviewers to this PR (or will leave a comment with the phrase "@{{reviewer_username}} PTAL" if I can't assign them directly).

## Proof that changes are correct

### Before

https://github.com/user-attachments/assets/c5dad0f4-bdff-4182-904a-da5277d61981

### After


https://github.com/user-attachments/assets/c92933cd-8085-4e3c-b534-e13d9a8b4562

## Important Information

This PR is only proven to work with Exploration Activities, not Collections. Per Issue #22354, there is a bug where the `[type]` dropdown does not recognize switching between Exploration and Collection, so there is no way to request information from the backend on a Collection ID. In Issue #20463 comments, consent to make this PR is in [this comment](https://github.com/oppia/oppia/issues/20463#issuecomment-2762178383). Please feel free to reach out for any questions.

## PR Pointers

- Never force push! If you do, your PR will be closed.
- To reply to reviewers, follow these instructions: https://github.com/oppia/oppia/wiki/Make-a-pull-request#step-5-address-review-comments-until-all-reviewers-approve
- Some e2e tests are flaky, and can fail for reasons unrelated to your PR. We are working on fixing this, but in the meantime, if you need to restart the tests, please check the ["If your build fails" wiki page](https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR).
- See the [Code Owner's wiki page](https://github.com/oppia/oppia/wiki/Oppia's-code-owners-and-checks-to-be-carried-out-by-developers) for what code owners will expect.



Diff:
diff --git a/core/controllers/moderator.py b/core/controllers/moderator.py
index d43fbd602eb83..960ccd0642e60 100644
--- a/core/controllers/moderator.py
+++ b/core/controllers/moderator.py
@@ -78,16 +78,101 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            (
+                non_existent_exploration_ids,
+                non_existent_collection_ids,
+                private_exploration_ids,
+                private_collection_ids
+            ) = (
+                summary_services.check_activity_id_validity(
                 featured_activity_references)
+            )
+
+            # If all of the lists are empty, there are no invalid IDs.
+            # Since there are no invalid IDs, the Featured Activity
+            # References are allowed to be updated.
+            if ((not non_existent_exploration_ids) &
+            (not non_existent_collection_ids) &
+            (not private_exploration_ids) &
+            (not private_collection_ids)
+            ):
+                activity_services.update_featured_activity_references(
+                featured_activity_references)
+                self.render_json({})
+            else:
+
+                # Create empty error message that can be added to
+                # with following, more specific error messages.
+                error_message = ''
+
+                # If there are IDs for non-existent Explorations,
+                # create new error message by joining all of the
+                # strings in the non_existent_exploration_ids list
+                # with the beginning component of the message.
+                if non_existent_exploration_ids:
+                    ids = ', '.join(map(str, non_existent_exploration_ids))
+                    error = (
+                        f'These Exploration IDs do not exist: '
+                        f'{ids}. '
+                    )
+                    # Join specific error with general error
+                    # message.
+                    error_message = error_message + error
+
+                # If there are IDs for non-existent Collections,
+                # create new error message by joining all of the
+                # strings in the non_existent_collection_ids
+                # list with the beginning component of the message.
+                if non_existent_collection_ids:
+                    ids = ', '.join(map(str, non_existent_collection_ids))
+                    error = (
+                        f'These Collection IDs do not exist: '
+                        f'{ids}. '
+                    )
+                    # Join specific error with general error
+                    # message.
+                    error_message = error_message + error
+
+                # If there are IDs for private Explorations,
+                # create new error message by joining all of the
+                # strings in the private_exploration_ids list with
+                # the beginning component of the message.
+                if private_exploration_ids:
+                    ids = ', '.join(map(str, private_exploration_ids))
+                    error = (
+                        f'These Exploration IDs are private: '
+                        f'{ids}. '
+                    )
+                    # Join specific error with general error
+                    # message.
+                    error_message = error_message + error
+
+                # If there are IDs for private Collections,
+                # create new error message by joining all of the
+                # strings in the private_collection_ids list with
+                # the beginning component of the message.
+                if private_collection_ids:
+                    ids = ', '.join(map(str, private_collection_ids))
+                    error = (
+                        f'These Collection IDs are private: '
+                        f'{ids}. '
+                    )
+                    # Join specific error with general error
+                    # message.
+                    error_message = error_message + error
+
+                # Join large and general error message with final piece.
+                error_message = f'{error_message}Please enter a different ID.'
+
+                # Raise final error message composed of smaller, more
+                # specific error messages as an Exception to be caught by
+                # moderator-page.component.ts.
+                raise self.InvalidInputException(error_message)
+
         except Exception as e:
             raise self.InvalidInputException(e)
 
-        activity_services.update_featured_activity_references(
-            featured_activity_references)
-
-        self.render_json({})
-
 
 class EmailDraftHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]):
     """Provide default email templates for moderator emails."""
diff --git a/core/controllers/moderator_test.py b/core/controllers/moderator_test.py
index e7ba2e625f1fa..32f7ad661976a 100644
--- a/core/controllers/moderator_test.py
+++ b/core/controllers/moderator_test.py
@@ -28,6 +28,10 @@ class FeaturedActivitiesHandlerTests(test_utils.GenericTestBase):
 
     EXP_ID_1: Final = 'exp_id_1'
     EXP_ID_2: Final = 'exp_id_2'
+    EXP_ID_3: Final = 'exp_id_3'
+    COL_ID_1: Final = 'col_id_1'
+    COL_ID_2: Final = 'col_id_2'
+    COL_ID_3: Final = 'col_id_3'
     username = 'albert'
     user_email = 'albert@example.com'
 
@@ -43,13 +47,164 @@ def setUp(self) -> None:
 
         self.save_new_valid_exploration(self.EXP_ID_2, self.user_id)
 
-    def test_unpublished_activities_cannot_be_added_to_featured_list(
+        self.save_new_valid_collection(
+            self.COL_ID_1, self.user_id, exploration_id=self.EXP_ID_1)
+
+        rights_manager.publish_collection(self.user, self.COL_ID_1)
+
+        self.save_new_valid_collection(
+            self.COL_ID_2, self.user_id, exploration_id=self.EXP_ID_1)
+
+    def test_nonexistent_exploration_id_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a nonexistent exploration
+        # results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'exploration',
+                    'id': self.EXP_ID_3,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_nonexistent_collection_id_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a nonexistent collection
+        # results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'collection',
+                    'id': self.COL_ID_3,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_and_nonexistent_exploration_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a public exploration
+        # and a nonexistent exploration results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'exploration',
+                    'id': self.EXP_ID_1,
+                }, {
+                    'type': 'exploration',
+                    'id': self.EXP_ID_3,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_and_nonexistent_collection_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a public collection
+        # and a nonexistent collection results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'collection',
+                    'id': self.COL_ID_1,
+                }, {
+                    'type': 'collection',
+                    'id': self.COL_ID_3,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_nonexistent_exploration_and_collection_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a nonexistent exploration
+        # and a nonexistent collection results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'exploration',
+                    'id': self.EXP_ID_3,
+                }, {
+                    'type': 'collection',
+                    'id': self.COL_ID_3,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_exploration_and_nonexistent_collection_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a public exploration
+        # and a nonexistent collection results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'exploration',
+                    'id': self.EXP_ID_1,
+                }, {
+                    'type': 'collection',
+                    'id': self.COL_ID_3,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_collection_and_nonexistent_exploration_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a publlic collection
+        # and a nonexistent exploration results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'collection',
+                    'id': self.COL_ID_1,
+                }, {
+                    'type': 'exploration',
+                    'id': self.EXP_ID_3,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_private_exploration_id_should_fail(
         self
     ) -> None:
         self.login(self.MODERATOR_EMAIL)
         csrf_token = self.get_new_csrf_token()
 
-        # Posting a list that includes private activities results in an error.
+        # Posting a list that includes a private exploration
+        # results in an error.
+
         self.post_json(
             '/moderatorhandler/featured', {
                 'featured_activity_reference_dicts': [{
@@ -57,6 +212,35 @@ def test_unpublished_activities_cannot_be_added_to_featured_list(
                     'id': self.EXP_ID_2,
                 }],
             }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_private_collection_id_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a private collection
+        # results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'collection',
+                    'id': self.COL_ID_2,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_and_private_exploration_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a public exploration
+        # and private exploration results in an error.
+
         self.post_json(
             '/moderatorhandler/featured', {
                 'featured_activity_reference_dicts': [{
@@ -67,18 +251,114 @@ def test_unpublished_activities_cannot_be_added_to_featured_list(
                     'id': self.EXP_ID_2,
                 }],
             }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_and_private_collection_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a public exploration
+        # and private exploration results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'collection',
+                    'id': self.COL_ID_1,
+                }, {
+                    'type': 'collection',
+                    'id': self.COL_ID_2,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_private_exploration_and_private_collection_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
 
+        # Posting a list that includes a private exploration
+        # and private collection results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'exploration',
+                    'id': self.EXP_ID_2,
+                }, {
+                    'type': 'collection',
+                    'id': self.COL_ID_2,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_exploration_and_private_collection_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a public exploration
+        # and private collection results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'exploration',
+                    'id': self.EXP_ID_1,
+                }, {
+                    'type': 'collection',
+                    'id': self.COL_ID_2,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_collection_and_private_exploration_ids_should_fail(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+
+        # Posting a list that includes a public collection
+        # and private exploration results in an error.
+
+        self.post_json(
+            '/moderatorhandler/featured', {
+                'featured_activity_reference_dicts': [{
+                    'type': 'collection',
+                    'id': self.COL_ID_1,
+                }, {
+                    'type': 'exploration',
+                    'id': self.EXP_ID_2,
+                }],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_public_exploration_and_public_collection_ids_should_succeed(
+        self
+    ) -> None:
+        self.login(self.MODERATOR_EMAIL)
+        csrf_token = self.get_new_csrf_token()
         # Posting a list that only contains public activities succeeds.
+
+        # Public Exploration & Public Collection.
         self.post_json(
             '/moderatorhandler/featured', {
                 'featured_activity_reference_dicts': [{
                     'type': 'exploration',
                     'id': self.EXP_ID_1,
+                }, {
+                    'type': 'collection',
+                    'id': self.COL_ID_1,
                 }],
             }, csrf_token=csrf_token)
         featured_activity_references = self.get_json(
             '/moderatorhandler/featured')['featured_activity_references']
         self.assertEqual(featured_activity_references[0]['id'], self.EXP_ID_1)
+        self.assertEqual(featured_activity_references[1]['id'], self.COL_ID_1)
         self.logout()
 
 
diff --git a/core/domain/summary_services.py b/core/domain/summary_services.py
index 6ff001c3e85a6..7ce4146b69c20 100644
--- a/core/domain/summary_services.py
+++ b/core/domain/summary_services.py
@@ -692,19 +692,19 @@ def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]:
     return results
 
 
-def require_activities_to_be_public(
+def check_activity_id_validity(
     activity_references: List[activity_domain.ActivityReference]
-) -> None:
-    """Raises an exception if any activity reference in the list does not
-    exist, or is not public.
+) -> Tuple[List[str], List[str], List[str], List[str]]:
+    """Returns four lists of each type of invalid Activity ID.
 
     Args:
         activity_references: list(ActivityReference). A list of
             ActivityReference domain objects.
 
-    Raises:
-        Exception. Any activity reference in the list does not
-            exist, or is not public.
+    Returns:
+        Tuple[List[str], List[str], List[str], List[str]]. 
+        The Tuple contains 4 Lists of string type. Each List
+        corresponds to a type of Invalid ID.
     """
     exploration_ids, collection_ids = activity_services.split_by_type(
         activity_references)
@@ -721,16 +721,54 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    # Initialize lists for each type of invalid IDs.
+    non_existent_exploration_ids = []
+    non_existent_collection_ids = []
+    private_exploration_ids = []
+    private_collection_ids = []
+
     for activities_info in activity_summaries_by_type:
-        for index, summary in enumerate(activities_info['summaries']):
-            if summary is None:
-                raise Exception(
-                    'Cannot feature non-existent %s with id %s' %
-                    (activities_info['type'], activities_info['ids'][index]))
-            if summary.status == rights_domain.ACTIVITY_STATUS_PRIVATE:
-                raise Exception(
-                    'Cannot feature private %s with id %s' %
-                    (activities_info['type'], activities_info['ids'][index]))
+
+        # If the activity is an Exploration.
+        if activities_info['type'] == constants.ACTIVITY_TYPE_EXPLORATION:
+            for index, summary in enumerate(activities_info['summaries']):
+                # If there's no summary for the Exploration ID,
+                # it means it doesn't exist. ID string is added to
+                # non_existent_exploration_ids list.
+                if summary is None:
+                    non_existent_exploration_ids.append(
+                        activities_info['ids'][index]
+                    )
+                # If the Exploration is set to private. ID string is added to
+                # private_exploration_ids list.
+                elif summary.status == rights_domain.ACTIVITY_STATUS_PRIVATE:
+                    private_exploration_ids.append(
+                        activities_info['ids'][index]
+                    )
+        # If the activity isn't an Exploration, it's a Collection.
+        else:
+            for index, summary in enumerate(activities_info['summaries']):
+                # If there's no summary for the Collection ID,
+                # it means it doesn't exist. ID string is added to
+                # non_existent_collection_ids list.
+                if summary is None:
+                    non_existent_collection_ids.append(
+                        activities_info['ids'][index]
+                    )
+                # If the Collection is set to private. ID string is added to
+                # private_collection_ids list.
+                elif summary.status == rights_domain.ACTIVITY_STATUS_PRIVATE:
+                    private_collection_ids.append(
+                        activities_info['ids'][index]
+                    )
+
+    # Return Tuple of lists containing any invalid IDs.
+    return (
+                non_existent_exploration_ids,
+                non_existent_collection_ids,
+                private_exploration_ids,
+                private_collection_ids
+            )
 
 
 def get_featured_activity_summary_dicts(
diff --git a/core/domain/summary_services_test.py b/core/domain/summary_services_test.py
index cfd95b299fe0c..460c220f6363e 100644
--- a/core/domain/summary_services_test.py
+++ b/core/domain/summary_services_test.py
@@ -907,7 +907,8 @@ def test_for_recently_published_explorations(self) -> None:
 
 
 class ActivityReferenceAccessCheckerTests(test_utils.GenericTestBase):
-    """Tests for requiring that activity references are public."""
+    """Tests for checking id validity of activities 
+    that don't exist or are private."""
 
     EXP_ID_0: Final = 'exp_id_0'
     EXP_ID_1: Final = 'exp_id_1'
@@ -919,19 +920,44 @@ def setUp(self) -> None:
         self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
         self.owner = user_services.get_user_actions_info(self.owner_id)
 
-    def test_requiring_nonexistent_activities_be_public_raises_exception(
+    def test_checking_activity_id_validity_on_nonexistent_activities(
         self
     ) -> None:
-        with self.assertRaisesRegex(Exception, 'non-existent exploration'):
-            summary_services.require_activities_to_be_public([
-                activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_EXPLORATION, 'fake')])
-        with self.assertRaisesRegex(Exception, 'non-existent collection'):
-            summary_services.require_activities_to_be_public([
-                activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_COLLECTION, 'fake')])
+        non_existent_exploration_id = 'non-existent exploration'
+        non_existent_collection_id = 'non-existent collection'
+
+        non_existent_activity_ids = [
+            activity_domain.ActivityReference(
+                constants.ACTIVITY_TYPE_EXPLORATION,
+                non_existent_exploration_id
+            ),
+            activity_domain.ActivityReference(
+                constants.ACTIVITY_TYPE_COLLECTION,
+                non_existent_collection_id
+            )
+        ]
+
+        (
+            non_existent_exploration_ids,
+            non_existent_collection_ids,
+            private_exploration_ids,
+            private_collection_ids
+        ) = summary_services.check_activity_id_validity(
+            non_existent_activity_ids
+        )
 
-    def test_requiring_private_activities_to_be_public_raises_exception(
+        self.assertIn(
+            non_existent_exploration_id,
+            non_existent_exploration_ids
+        )
+        self.assertIn(
+            non_existent_collection_id,
+            non_existent_collection_ids
+        )
+        self.assertEqual(private_exploration_ids, [])
+        self.assertEqual(private_collection_ids, [])
+
+    def test_checking_activity_id_validity_on_private_activities(
         self
     ) -> None:
         self.save_new_valid_exploration(self.EXP_ID_0, self.owner_id)
@@ -939,16 +965,28 @@ def test_requiring_private_activities_to_be_public_raises_exception(
         self.save_new_valid_collection(
             self.COL_ID_2, self.owner_id, exploration_id=self.EXP_ID_0)
 
-        with self.assertRaisesRegex(Exception, 'private exploration'):
-            summary_services.require_activities_to_be_public([
+        private_activity_ids = [
                 activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_EXPLORATION, self.EXP_ID_0)])
-        with self.assertRaisesRegex(Exception, 'private collection'):
-            summary_services.require_activities_to_be_public([
+                    constants.ACTIVITY_TYPE_EXPLORATION, self.EXP_ID_0),
                 activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_COLLECTION, self.COL_ID_2)])
+                    constants.ACTIVITY_TYPE_COLLECTION, self.COL_ID_2)
+        ]
+
+        (
+            non_existent_exploration_ids,
+            non_existent_collection_ids,
+            private_exploration_ids,
+            private_collection_ids
+        ) = summary_services.check_activity_id_validity(
+            private_activity_ids
+        )
 
-    def test_requiring_public_activities_to_be_public_succeeds(self) -> None:
+        self.assertEqual(non_existent_exploration_ids, [])
+        self.assertEqual(non_existent_collection_ids, [])
+        self.assertIn(self.EXP_ID_0, private_exploration_ids)
+        self.assertIn(self.COL_ID_2, private_collection_ids)
+
+    def test_checking_activity_id_validity_on_valid_activities(self) -> None:
         self.save_new_valid_exploration(self.EXP_ID_0, self.owner_id)
         self.save_new_valid_collection(
             self.COL_ID_2, self.owner_id, exploration_id=self.EXP_ID_0)
@@ -956,12 +994,26 @@ def test_requiring_public_activities_to_be_public_succeeds(self) -> None:
         rights_manager.publish_exploration(self.owner, self.EXP_ID_0)
         rights_manager.publish_collection(self.owner, self.COL_ID_2)
 
-        # There are no validation errors.
-        summary_services.require_activities_to_be_public([
+        valid_activity_ids = [
             activity_domain.ActivityReference(
                 constants.ACTIVITY_TYPE_EXPLORATION, self.EXP_ID_0),
             activity_domain.ActivityReference(
-                constants.ACTIVITY_TYPE_COLLECTION, self.COL_ID_2)])
+                constants.ACTIVITY_TYPE_COLLECTION, self.COL_ID_2)
+        ]
+
+        (
+            non_existent_exploration_ids,
+            non_existent_collection_ids,
+            private_exploration_ids,
+            private_collection_ids
+        ) = summary_services.check_activity_id_validity(
+            valid_activity_ids
+        )
+
+        self.assertEqual(non_existent_exploration_ids, [])
+        self.assertEqual(non_existent_collection_ids, [])
+        self.assertEqual(private_exploration_ids, [])
+        self.assertEqual(private_collection_ids, [])
 
 
 class CollectionNodeMetadataDictsTest(
diff --git a/core/templates/pages/moderator-page/moderator-page.component.spec.ts b/core/templates/pages/moderator-page/moderator-page.component.spec.ts
index cdc81b11593b5..49bd661268a19 100644
--- a/core/templates/pages/moderator-page/moderator-page.component.spec.ts
+++ b/core/templates/pages/moderator-page/moderator-page.component.spec.ts
@@ -114,6 +114,9 @@ describe('Moderator Page Component', () => {
       return {
         then: (successCallback: () => void) => {
           successCallback();
+          return {
+            catch: () => {},
+          };
         },
       };
     }
@@ -213,14 +216,14 @@ describe('Moderator Page Component', () => {
   });
 
   it('should tell if save featured activies button is disabled', () => {
-    componentInstance.displayedFeaturedActivityReferences =
-      componentInstance.lastSavedFeaturedActivityReferences;
+    componentInstance.displayedFeaturedActivityReferences = [
+      {id: '', type: 'emptyID'},
+    ];
     expect(
       componentInstance.isSaveFeaturedActivitiesButtonDisabled()
     ).toBeTrue();
-    componentInstance.displayedFeaturedActivityReferences = [];
-    componentInstance.lastSavedFeaturedActivityReferences = [
-      {id: 'not', type: 'equal'},
+    componentInstance.displayedFeaturedActivityReferences = [
+      {id: 'is', type: 'not'},
     ];
     expect(
       componentInstance.isSaveFeaturedActivitiesButtonDisabled()
@@ -254,4 +257,208 @@ describe('Moderator Page Component', () => {
       newValue
     );
   });
+
+  it('should display error message for nonexistent exploration', () => {
+    spyOn(alertsService, 'addWarning');
+
+    let newValue: ActivityIdTypeDict[] = [
+      {
+        id: 'dne_exploration',
+        type: 'exploration',
+      },
+    ];
+
+    componentInstance.displayedFeaturedActivityReferences = [];
+    componentInstance.updateDisplayedFeaturedActivityReferences(newValue);
+
+    const mockError = {
+      status: 400,
+      error: {
+        error:
+          'These Exploration IDs do not exist: dne_exploration. Please enter a different ID.',
+      },
+    };
+
+    spyOn(
+      componentInstance.moderatorPageBackendApiService,
+      'saveFeaturedActivityReferencesAsync'
+    ).and.callFake(() => {
+      return {
+        then: () => {
+          return {
+            catch: (errorCallback: (err: string) => void) => {
+              errorCallback(mockError);
+            },
+          };
+        },
+      };
+    });
+
+    componentInstance.saveFeaturedActivityReferences();
+
+    expect(alertsService.addWarning).toHaveBeenCalledWith(
+      'These Exploration IDs do not exist: dne_exploration. Please enter a different ID.'
+    );
+  });
+
+  it('should display error message for nonexistent collection', () => {
+    spyOn(alertsService, 'addWarning');
+
+    let newValue: ActivityIdTypeDict[] = [
+      {
+        id: 'dne_collection',
+        type: 'collection',
+      },
+    ];
+
+    componentInstance.displayedFeaturedActivityReferences = [];
+    componentInstance.updateDisplayedFeaturedActivityReferences(newValue);
+
+    const mockError = {
+      status: 400,
+      error: {
+        error:
+          'These Collection IDs do not exist: dne_collection. Please enter a different ID.',
+      },
+    };
+
+    spyOn(
+      componentInstance.moderatorPageBackendApiService,
+      'saveFeaturedActivityReferencesAsync'
+    ).and.callFake(() => {
+      return {
+        then: () => {
+          return {
+            catch: (errorCallback: (err: string) => void) => {
+              errorCallback(mockError);
+            },
+          };
+        },
+      };
+    });
+
+    componentInstance.saveFeaturedActivityReferences();
+
+    expect(alertsService.addWarning).toHaveBeenCalledWith(
+      'These Collection IDs do not exist: dne_collection. Please enter a different ID.'
+    );
+  });
+
+  it('should display error message for private exploration', () => {
+    spyOn(alertsService, 'addWarning');
+
+    let newValue: ActivityIdTypeDict[] = [
+      {
+        id: 'priv_exploration',
+        type: 'exploration',
+      },
+    ];
+
+    componentInstance.displayedFeaturedActivityReferences = [];
+    componentInstance.updateDisplayedFeaturedActivityReferences(newValue);
+
+    const mockError = {
+      status: 400,
+      error: {
+        error:
+          'These Exploration IDs are private: priv_exploration. Please enter a different ID.',
+      },
+    };
+
+    spyOn(
+      componentInstance.moderatorPageBackendApiService,
+      'saveFeaturedActivityReferencesAsync'
+    ).and.callFake(() => {
+      return {
+        then: () => {
+          return {
+            catch: (errorCallback: (err: string) => void) => {
+              errorCallback(mockError);
+            },
+          };
+        },
+      };
+    });
+
+    componentInstance.saveFeaturedActivityReferences();
+
+    expect(alertsService.addWarning).toHaveBeenCalledWith(
+      'These Exploration IDs are private: priv_exploration. Please enter a different ID.'
+    );
+  });
+
+  it('should display error message for private collection', () => {
+    spyOn(alertsService, 'addWarning');
+
+    let newValue: ActivityIdTypeDict[] = [
+      {
+        id: 'priv_collection',
+        type: 'collection',
+      },
+    ];
+
+    componentInstance.displayedFeaturedActivityReferences = [];
+    componentInstance.updateDisplayedFeaturedActivityReferences(newValue);
+
+    const mockError = {
+      status: 400,
+      error: {
+        error:
+          'These Collection IDs are private: priv_collection. Please enter a different ID.',
+      },
+    };
+
+    spyOn(
+      componentInstance.moderatorPageBackendApiService,
+      'saveFeaturedActivityReferencesAsync'
+    ).and.callFake(() => {
+      return {
+        then: () => {
+          return {
+            catch: (errorCallback: (err: string) => void) => {
+              errorCallback(mockError);
+            },
+          };
+        },
+      };
+    });
+
+    componentInstance.saveFeaturedActivityReferences();
+
+    expect(alertsService.addWarning).toHaveBeenCalledWith(
+      'These Collection IDs are private: priv_collection. Please enter a different ID.'
+    );
+  });
+
+  it('should display message for miscellaneous errors', () => {
+    spyOn(alertsService, 'addWarning');
+
+    const mockError = {
+      status: 404,
+      error: {
+        error: '',
+      },
+    };
+
+    spyOn(
+      componentInstance.moderatorPageBackendApiService,
+      'saveFeaturedActivityReferencesAsync'
+    ).and.callFake(() => {
+      return {
+        then: () => {
+          return {
+            catch: (errorCallback: (err: string) => void) => {
+              errorCallback(mockError);
+            },
+          };
+        },
+      };
+    });
+
+    componentInstance.saveFeaturedActivityReferences();
+
+    expect(alertsService.addWarning).toHaveBeenCalledWith(
+      'An unexpected error occurred. Please try again later.'
+    );
+  });
 });
diff --git a/core/templates/pages/moderator-page/moderator-page.component.ts b/core/templates/pages/moderator-page/moderator-page.component.ts
index a5490d00ee1b3..f9ddb8e3a3790 100644
--- a/core/templates/pages/moderator-page/moderator-page.component.ts
+++ b/core/templates/pages/moderator-page/moderator-page.component.ts
@@ -19,7 +19,6 @@
 import {ChangeDetectorRef, Component} from '@angular/core';
 import {AppConstants} from 'app.constants';
 import {ThreadMessage} from 'domain/feedback_message/ThreadMessage.model';
-import isEqual from 'lodash/isEqual';
 import {AlertsService} from 'services/alerts.service';
 import {DateTimeFormatService} from 'services/date-time-format.service';
 import {LoaderService} from 'services/loader.service';
@@ -146,10 +145,22 @@ export class ModeratorPageComponent {
   }
 
   isSaveFeaturedActivitiesButtonDisabled(): boolean {
-    return isEqual(
-      this.displayedFeaturedActivityReferences,
-      this.lastSavedFeaturedActivityReferences
-    );
+    // Declare new variable as false so that the button is not disabled
+    // when there are no elements on the Moderator page.
+    var isDisabled: boolean = false;
+    for (let reference of this.displayedFeaturedActivityReferences) {
+      // If the [id] input field is blank, isDiasabled is set as true which will
+      // be returned and disable the Save Featured Activities button.
+      if (reference.id.trim() === '') {
+        isDisabled = true;
+        // When something is typed into the [id] input, isDisabled is set as
+        // false, which is then returned making the Save Featured Activities
+        // button no longer disabled.
+      } else {
+        isDisabled = false;
+      }
+    }
+    return isDisabled;
   }
 
   saveFeaturedActivityReferences(): void {
@@ -164,6 +175,17 @@ export class ModeratorPageComponent {
       .then(() => {
         this.lastSavedFeaturedActivityReferences = activityReferencesToSave;
         this.alertsService.addSuccessMessage('Featured activities saved.');
+      })
+      // Catches 400 error returned from backend and displays the custom
+      // and corresponding error message created in moderator.py.
+      .catch(error => {
+        if (error.status === 400 && error.error) {
+          this.alertsService.addWarning(error.error.error);
+        } else {
+          this.alertsService.addWarning(
+            'An unexpected error occurred. Please try again later.'
+          );
+        }
       });
   }
 


Reviews:
user: login: Nik-09
body: Thanks @charliemax5, I have added a few comments.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-18T17:48:30Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T19:11:56Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T19:30:01Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T19:30:17Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T19:30:30Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T19:30:51Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T19:30:57Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T19:32:24Z
user: login: Nik-09
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T18:53:11Z
user: login: Nik-09
body: Added comments.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-02T18:54:50Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T18:57:28Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T20:13:14Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T20:13:41Z
user: login: Nik-09
body: Added few more comments
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-05T12:58:04Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T17:30:07Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T18:10:17Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T18:10:23Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T18:10:42Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T18:11:25Z
user: login: charliemax5
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T18:11:45Z
user: login: Nik-09
body: Thanks @charliemax5 LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-10T06:42:58Z

Review comments:
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
path: core/controllers/moderator.py
user: login: Nik-09
body: Sorry, I didn't understand what these variable means. Can you rename these?
created_at: 2025-04-18T17:37:26Z
author_association: MEMBER
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
+                summary_services.check_activity_id_validity(
                 featured_activity_references)
-        except Exception as e:
-            raise self.InvalidInputException(e)
+            )
 
-        activity_services.update_featured_activity_references(
-            featured_activity_references)
+            # If all of the lists are empty, there are no invalid IDs.
+            if ((not dne_exp) & (not dne_col) &
+                (not priv_exp) & (not priv_col)):
+                activity_services.update_featured_activity_references(
+                featured_activity_references)
+                self.render_json({})
+            else:
+
+                error_message = ''
+
+                # If there are IDs for non-existent Explorations.
+                if dne_exp:
+                    error = (
+                        f'These Exploration IDs do not exist: '
+                        f'{", ".join(str(id) for id in dne_exp)}. '
+                    )
+                    error_message = error_message + error
+
+                # If there are IDs for non-existent Collections.
+                if dne_col:
+                    error = (
+                        f'These Collection IDs do not exist: '
+                        f'{", ".join(str(id) for id in dne_col)}. '
+                    )
+                    error_message = error_message + error
+
+                # If there are IDs for private Explorations.
path: core/controllers/moderator.py
user: login: Nik-09
body: Here and many of the code comments are not required. If you still think this can improve understanding, please provide more context for the code comments.

_(I find little hard to understand some of the comments below )_ 
created_at: 2025-04-18T17:40:36Z
author_association: MEMBER
diff_hunk: @@ -43,20 +47,106 @@ def setUp(self) -> None:
 
         self.save_new_valid_exploration(self.EXP_ID_2, self.user_id)
 
-    def test_unpublished_activities_cannot_be_added_to_featured_list(
+        self.save_new_valid_collection(
+            self.COL_ID_1, self.user_id, exploration_id=self.EXP_ID_1)
+
+        rights_manager.publish_collection(self.user, self.COL_ID_1)
+
+        self.save_new_valid_collection(
+            self.COL_ID_2, self.user_id, exploration_id=self.EXP_ID_1)
+
+    def test_scenarios_for_valid_and_invalid_activity_ids(
path: core/controllers/moderator_test.py
user: login: Nik-09
body: I would suggest splitting this test case into multiple tests, consisting of 1 test/scenario. 
created_at: 2025-04-18T17:42:49Z
author_association: MEMBER
diff_hunk: @@ -692,19 +692,19 @@ def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]:
     return results
 
 
-def require_activities_to_be_public(
+def check_activity_id_validity(
     activity_references: List[activity_domain.ActivityReference]
-) -> None:
-    """Raises an exception if any activity reference in the list does not
-    exist, or is not public.
+) -> Tuple[List[str], List[str], List[str], List[str]]:
path: core/domain/summary_services.py
user: login: Nik-09
body: The return type mentioned in line 707 does not match to this.
created_at: 2025-04-18T17:43:20Z
author_association: MEMBER
diff_hunk: @@ -721,16 +721,35 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    dne_exp = []
path: core/domain/summary_services.py
user: login: Nik-09
body: Ditto here and below, please make these variable names more meaningful.
created_at: 2025-04-18T17:45:23Z
author_association: MEMBER
diff_hunk: @@ -164,6 +175,17 @@ export class ModeratorPageComponent {
       .then(() => {
         this.lastSavedFeaturedActivityReferences = activityReferencesToSave;
         this.alertsService.addSuccessMessage('Featured activities saved.');
+      })
+      // Catches 400 error returned from backend and displays the custom
+      // and corresponding error message created in moderator.py.
+      .catch(error => {
+        if (error.status === 400 && error.error) {
+          this.alertsService.addWarning(error.error.error);
+        } else {
+          this.alertsService.addWarning(
+            'An unexpected error occurred. Please try again later.'
path: core/templates/pages/moderator-page/moderator-page.component.ts
user: login: Nik-09
body: Even if the error code is not 400, we can still show the error message (`error.error`), right?
created_at: 2025-04-18T17:47:19Z
author_association: MEMBER
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
path: core/controllers/moderator.py
user: login: charliemax5
body: I made the variable names short to fit the Linter line-length requirement. Could I include code comments to explain what each variable is our should I just change the variable names?
created_at: 2025-04-18T19:11:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
path: core/controllers/moderator.py
user: login: charliemax5
body: I added more code comments explaining what each variable name means.
created_at: 2025-04-25T19:30:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
+                summary_services.check_activity_id_validity(
                 featured_activity_references)
-        except Exception as e:
-            raise self.InvalidInputException(e)
+            )
 
-        activity_services.update_featured_activity_references(
-            featured_activity_references)
+            # If all of the lists are empty, there are no invalid IDs.
+            if ((not dne_exp) & (not dne_col) &
+                (not priv_exp) & (not priv_col)):
+                activity_services.update_featured_activity_references(
+                featured_activity_references)
+                self.render_json({})
+            else:
+
+                error_message = ''
+
+                # If there are IDs for non-existent Explorations.
+                if dne_exp:
+                    error = (
+                        f'These Exploration IDs do not exist: '
+                        f'{", ".join(str(id) for id in dne_exp)}. '
+                    )
+                    error_message = error_message + error
+
+                # If there are IDs for non-existent Collections.
+                if dne_col:
+                    error = (
+                        f'These Collection IDs do not exist: '
+                        f'{", ".join(str(id) for id in dne_col)}. '
+                    )
+                    error_message = error_message + error
+
+                # If there are IDs for private Explorations.
path: core/controllers/moderator.py
user: login: charliemax5
body: I reworded many comments to better explain what is happening and added more comments to explain other functions that didn't have comments before.
created_at: 2025-04-25T19:30:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,20 +47,106 @@ def setUp(self) -> None:
 
         self.save_new_valid_exploration(self.EXP_ID_2, self.user_id)
 
-    def test_unpublished_activities_cannot_be_added_to_featured_list(
+        self.save_new_valid_collection(
+            self.COL_ID_1, self.user_id, exploration_id=self.EXP_ID_1)
+
+        rights_manager.publish_collection(self.user, self.COL_ID_1)
+
+        self.save_new_valid_collection(
+            self.COL_ID_2, self.user_id, exploration_id=self.EXP_ID_1)
+
+    def test_scenarios_for_valid_and_invalid_activity_ids(
path: core/controllers/moderator_test.py
user: login: charliemax5
body: I split each scenario into its own test all under the `FeaturedActivitiesHandlerTests` class.
created_at: 2025-04-25T19:30:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -692,19 +692,19 @@ def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]:
     return results
 
 
-def require_activities_to_be_public(
+def check_activity_id_validity(
     activity_references: List[activity_domain.ActivityReference]
-) -> None:
-    """Raises an exception if any activity reference in the list does not
-    exist, or is not public.
+) -> Tuple[List[str], List[str], List[str], List[str]]:
path: core/domain/summary_services.py
user: login: charliemax5
body: Corrected docstring Return Type description.
created_at: 2025-04-25T19:30:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -721,16 +721,35 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    dne_exp = []
path: core/domain/summary_services.py
user: login: charliemax5
body: Added code comments explaining what the variable names mean.
created_at: 2025-04-25T19:30:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,6 +175,17 @@ export class ModeratorPageComponent {
       .then(() => {
         this.lastSavedFeaturedActivityReferences = activityReferencesToSave;
         this.alertsService.addSuccessMessage('Featured activities saved.');
+      })
+      // Catches 400 error returned from backend and displays the custom
+      // and corresponding error message created in moderator.py.
+      .catch(error => {
+        if (error.status === 400 && error.error) {
+          this.alertsService.addWarning(error.error.error);
+        } else {
+          this.alertsService.addWarning(
+            'An unexpected error occurred. Please try again later.'
path: core/templates/pages/moderator-page/moderator-page.component.ts
user: login: charliemax5
body: It should be a 400 error every time which is the only error code the messages would correspond to. The last `else` handles other error codes.
created_at: 2025-04-25T19:32:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
path: core/controllers/moderator.py
user: login: Nik-09
body: Instead of code comments I would prefer these names:
non_existent_explorations, non_existent_collections, private_explorations, private_collections

created_at: 2025-05-02T18:50:40Z
author_association: MEMBER
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
path: core/controllers/moderator.py
user: login: Nik-09
body: Additionally, please do not resolve the comments; leave them for the reviewers.


created_at: 2025-05-02T18:53:11Z
author_association: MEMBER
diff_hunk: @@ -721,16 +721,35 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    dne_exp = []
path: core/domain/summary_services.py
user: login: Nik-09
body: Ditto as above comment
created_at: 2025-05-02T18:53:41Z
author_association: MEMBER
diff_hunk: @@ -164,6 +175,17 @@ export class ModeratorPageComponent {
       .then(() => {
         this.lastSavedFeaturedActivityReferences = activityReferencesToSave;
         this.alertsService.addSuccessMessage('Featured activities saved.');
+      })
+      // Catches 400 error returned from backend and displays the custom
+      // and corresponding error message created in moderator.py.
+      .catch(error => {
+        if (error.status === 400 && error.error) {
+          this.alertsService.addWarning(error.error.error);
+        } else {
+          this.alertsService.addWarning(
+            'An unexpected error occurred. Please try again later.'
path: core/templates/pages/moderator-page/moderator-page.component.ts
user: login: Nik-09
body: Makes sense
created_at: 2025-05-02T18:54:36Z
author_association: MEMBER
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
path: core/controllers/moderator.py
user: login: charliemax5
body: @Nik-09 I will change the variable names per your request

created_at: 2025-05-02T18:57:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -721,16 +721,35 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    dne_exp = []
path: core/domain/summary_services.py
user: login: charliemax5
body: Changed the variable names to suggested replacements
created_at: 2025-05-02T20:13:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,15 +78,60 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            dne_exp, dne_col, priv_exp, priv_col = (
path: core/controllers/moderator.py
user: login: charliemax5
body: I have changed the variable names to the suggested replacements.
created_at: 2025-05-02T20:13:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,16 +78,99 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            (
+                non_existent_explorations,
+                non_existent_collections,
+                private_explorations,
+                private_collections
+            ) = (
+                summary_services.check_activity_id_validity(
                 featured_activity_references)
+            )
+
+            # If all of the lists are empty, there are no invalid IDs.
+            # Since there are no invalid IDs, the Featured Activity
+            # References are allowed to be updated.
+            if ((not non_existent_explorations) &
+                (not non_existent_collections) &
+                (not private_explorations) &
+                (not private_collections)
+                ):
path: core/controllers/moderator.py
user: login: Nik-09
body: de-indent by 1 tab space.
created_at: 2025-05-05T12:45:16Z
author_association: MEMBER
diff_hunk: @@ -43,20 +47,200 @@ def setUp(self) -> None:
 
         self.save_new_valid_exploration(self.EXP_ID_2, self.user_id)
 
-    def test_unpublished_activities_cannot_be_added_to_featured_list(
+        self.save_new_valid_collection(
+            self.COL_ID_1, self.user_id, exploration_id=self.EXP_ID_1)
+
+        rights_manager.publish_collection(self.user, self.COL_ID_1)
+
+        self.save_new_valid_collection(
+            self.COL_ID_2, self.user_id, exploration_id=self.EXP_ID_1)
+
+    def test_nonexistent_exploration_id(
path: core/controllers/moderator_test.py
user: login: Nik-09
body: What this test case is checking? There is no assert statement.
Also, rephrase the test name with the behaviour that is expected, like:
`test_non_existent_exploration_ids_should_fail` or something around these lines.
created_at: 2025-05-05T12:49:57Z
author_association: MEMBER
diff_hunk: @@ -43,20 +47,200 @@ def setUp(self) -> None:
 
         self.save_new_valid_exploration(self.EXP_ID_2, self.user_id)
 
-    def test_unpublished_activities_cannot_be_added_to_featured_list(
+        self.save_new_valid_collection(
+            self.COL_ID_1, self.user_id, exploration_id=self.EXP_ID_1)
+
+        rights_manager.publish_collection(self.user, self.COL_ID_1)
+
+        self.save_new_valid_collection(
+            self.COL_ID_2, self.user_id, exploration_id=self.EXP_ID_1)
+
+    def test_nonexistent_exploration_id(
path: core/controllers/moderator_test.py
user: login: Nik-09
body: Ditto for other test cases
created_at: 2025-05-05T12:50:08Z
author_association: MEMBER
diff_hunk: @@ -692,19 +692,23 @@ def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]:
     return results
 
 
-def require_activities_to_be_public(
+def check_activity_id_validity(
     activity_references: List[activity_domain.ActivityReference]
-) -> None:
-    """Raises an exception if any activity reference in the list does not
-    exist, or is not public.
+) -> Tuple[List[str], List[str], List[str], List[str]]:
+    """Returns four lists of each type of invalid Activity ID.
 
     Args:
         activity_references: list(ActivityReference). A list of
             ActivityReference domain objects.
 
-    Raises:
-        Exception. Any activity reference in the list does not
-            exist, or is not public.
+    Returns:
+        Tuple[List[str], List[str], List[str], List[str]]. 
+        The Tuple contains 4 Lists of string type. Each List
+        corresponds to a type of Invalid ID: 
+        dne_exp = nonexistent Explorations,
path: core/domain/summary_services.py
user: login: Nik-09
body: Why here and below still contain `dne_exp` and similar names? We discussed in the previous comments, to rename these variables more meaningfully.
Please update this.
created_at: 2025-05-05T12:52:36Z
author_association: MEMBER
diff_hunk: @@ -721,16 +725,54 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    # Initialize lists for each type of invalid ID.
path: core/domain/summary_services.py
user: login: Nik-09
body: > ``
```suggestion
    # Initialize lists for each type of invalid IDs.
```
created_at: 2025-05-05T12:53:17Z
author_association: MEMBER
diff_hunk: @@ -721,16 +725,54 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    # Initialize lists for each type of invalid ID.
+    non_existent_explorations = []
path: core/domain/summary_services.py
user: login: Nik-09
body: These data structures do not store the exploration object, right?
Can we rename to `non_existent_exploration_ids` and similar for others?
created_at: 2025-05-05T12:55:09Z
author_association: MEMBER
diff_hunk: @@ -721,16 +725,54 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    # Initialize lists for each type of invalid ID.
+    non_existent_explorations = []
path: core/domain/summary_services.py
user: login: Nik-09
body: Correct me if I am misunderstanding this
created_at: 2025-05-05T12:55:32Z
author_association: MEMBER
diff_hunk: @@ -919,49 +920,81 @@ def setUp(self) -> None:
         self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
         self.owner = user_services.get_user_actions_info(self.owner_id)
 
-    def test_requiring_nonexistent_activities_be_public_raises_exception(
+    def test_checking_activity_id_validity_on_nonexistent_activities(
         self
     ) -> None:
-        with self.assertRaisesRegex(Exception, 'non-existent exploration'):
-            summary_services.require_activities_to_be_public([
-                activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_EXPLORATION, 'fake')])
-        with self.assertRaisesRegex(Exception, 'non-existent collection'):
-            summary_services.require_activities_to_be_public([
-                activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_COLLECTION, 'fake')])
+        dne_exp_id = 'non-existent exploration'
path: core/domain/summary_services_test.py
user: login: Nik-09
body: Ditto update the variable name
created_at: 2025-05-05T12:56:13Z
author_association: MEMBER
diff_hunk: @@ -43,20 +47,200 @@ def setUp(self) -> None:
 
         self.save_new_valid_exploration(self.EXP_ID_2, self.user_id)
 
-    def test_unpublished_activities_cannot_be_added_to_featured_list(
+        self.save_new_valid_collection(
+            self.COL_ID_1, self.user_id, exploration_id=self.EXP_ID_1)
+
+        rights_manager.publish_collection(self.user, self.COL_ID_1)
+
+        self.save_new_valid_collection(
+            self.COL_ID_2, self.user_id, exploration_id=self.EXP_ID_1)
+
+    def test_nonexistent_exploration_id(
path: core/controllers/moderator_test.py
user: login: charliemax5
body: The test cases are checking whether or not a 400 Error Code is being detected. That would mean that it is correctly determining that the inputted Activity IDs are invalid and the correct Error Code is being caught. If I were to add an assert statement, it would probably just check that `featuredActivityReferences` list would be empty.  I can add an assert statement if you'd like, but before I edited the test file it was just checking for the 400 Error like I am.

I will change the names of the tests now, but let me know if I need to add an assert statement.
created_at: 2025-05-07T17:30:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -919,49 +920,81 @@ def setUp(self) -> None:
         self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
         self.owner = user_services.get_user_actions_info(self.owner_id)
 
-    def test_requiring_nonexistent_activities_be_public_raises_exception(
+    def test_checking_activity_id_validity_on_nonexistent_activities(
         self
     ) -> None:
-        with self.assertRaisesRegex(Exception, 'non-existent exploration'):
-            summary_services.require_activities_to_be_public([
-                activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_EXPLORATION, 'fake')])
-        with self.assertRaisesRegex(Exception, 'non-existent collection'):
-            summary_services.require_activities_to_be_public([
-                activity_domain.ActivityReference(
-                    constants.ACTIVITY_TYPE_COLLECTION, 'fake')])
+        dne_exp_id = 'non-existent exploration'
path: core/domain/summary_services_test.py
user: login: charliemax5
body: Changed variable names to new format.
created_at: 2025-05-07T18:10:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -721,16 +725,54 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    # Initialize lists for each type of invalid ID.
+    non_existent_explorations = []
path: core/domain/summary_services.py
user: login: charliemax5
body: You are correct. The lists just contain the ID strings for different Activities, not the actual Activity Objects. I have changed the names to better reflect that.
created_at: 2025-05-07T18:10:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -721,16 +725,54 @@ def require_activities_to_be_public(
             collection_ids),
     }]
 
+    # Initialize lists for each type of invalid ID.
path: core/domain/summary_services.py
user: login: charliemax5
body: Added 's' to code comment.
created_at: 2025-05-07T18:10:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -692,19 +692,23 @@ def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]:
     return results
 
 
-def require_activities_to_be_public(
+def check_activity_id_validity(
     activity_references: List[activity_domain.ActivityReference]
-) -> None:
-    """Raises an exception if any activity reference in the list does not
-    exist, or is not public.
+) -> Tuple[List[str], List[str], List[str], List[str]]:
+    """Returns four lists of each type of invalid Activity ID.
 
     Args:
         activity_references: list(ActivityReference). A list of
             ActivityReference domain objects.
 
-    Raises:
-        Exception. Any activity reference in the list does not
-            exist, or is not public.
+    Returns:
+        Tuple[List[str], List[str], List[str], List[str]]. 
+        The Tuple contains 4 Lists of string type. Each List
+        corresponds to a type of Invalid ID: 
+        dne_exp = nonexistent Explorations,
path: core/domain/summary_services.py
user: login: charliemax5
body: Removed irrelevant/incorrect docstring text and updated variable names to new format.
created_at: 2025-05-07T18:11:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,16 +78,99 @@ def post(self) -> None:
             'featured_activity_reference_dicts']
 
         try:
-            summary_services.require_activities_to_be_public(
+            # Retrieve the list for each type of inavlid ID.
+            (
+                non_existent_explorations,
+                non_existent_collections,
+                private_explorations,
+                private_collections
+            ) = (
+                summary_services.check_activity_id_validity(
                 featured_activity_references)
+            )
+
+            # If all of the lists are empty, there are no invalid IDs.
+            # Since there are no invalid IDs, the Featured Activity
+            # References are allowed to be updated.
+            if ((not non_existent_explorations) &
+                (not non_existent_collections) &
+                (not private_explorations) &
+                (not private_collections)
+                ):
path: core/controllers/moderator.py
user: login: charliemax5
body: De-indented 1 tab space.
created_at: 2025-05-07T18:11:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: oppiabot[bot]
created_at: 2025-04-14T01:43:51Z
body: Hi @charliemax5, can you complete the following:
1. The proof that changes are correct has not been provided, please make sure to upload a image/video showing that the changes are correct. Or include a sentence saying "No proof of changes needed because" and the reason why proof of changes cannot be provided.
Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-04-14T01:44:01Z
body: Assigning @Nik-09 for the first pass review of this PR. Thanks!
author_association: NONE
user: login: charliemax5
created_at: 2025-04-14T01:46:18Z
body: @seanlip PTAL. Thank you for your help!
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-14T01:46:30Z
body: Assigned reviewers: @seanlip. Thank you!
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-14T01:52:39Z
body: 🛠️ Hi @charliemax5, it looks like the frontend tests are failing on your PR. Please look at the logs and follow [these instructions](https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR#introduction) to fix them, or report the error as a flake.
You might also find the [instructions for debugging frontend tests](https://github.com/oppia/oppia/wiki/Debug-frontend-tests) helpful.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-14T02:23:57Z
body: 🛠️ Hi @charliemax5, it looks like the backend coverage tests are failing on your PR. Please follow [these instructions](https://github.com/oppia/oppia/wiki/Backend-tests#coverage-reports) to fix the missing coverage.
author_association: NONE
user: login: Nik-09
created_at: 2025-04-18T05:27:59Z
body: Hi @charliemax5, I will take a look at the PR in a few hours (3-4). Until then, can you please prioritize fixing the CI checks? Thank you!
author_association: MEMBER
user: login: charliemax5
created_at: 2025-04-18T17:24:18Z
body: Hi @Nik-09

Thank you for taking a look. I just reran the Checks, but the Frontend Unit Tests seem to have failed. I checked the logs and it says that all 9710 Tests ran and Succeeded, but it still counted as failed. I am not sure why this is the case or how I should go about fixing it (if there actually is something to fix). Please let me know what I need to do to get the Tests to pass. 

Thanks!
author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-04-18T17:51:34Z
body: Unassigning @Nik-09 since the review is done.
author_association: NONE
user: login: Nik-09
created_at: 2025-04-18T17:51:57Z
body: 
Hi @charliemax5, in the logs I see `ERROR:root:core/templates/pages/moderator-page/moderator-page.component.ts seems to be not completely tested. Make sure it's fully covered.` [link](https://github.com/oppia/oppia/actions/runs/14538212789/job/40790826121?pr=22453#step:6:11267)

So I think this is a coverage issue related to your changes. 
Hope this helps, let me know if you have any further questions.

Thanks
author_association: MEMBER
user: login: charliemax5
created_at: 2025-04-18T19:09:46Z
body: Would your comment,
> I would suggest splitting this test case into multiple tests, consisting of 1 test/scenario.

Fix the CI error? Are there any cases that I missed, or is it just because they are all under the same test case? I feel like I thought of all possible scenarios. 

Please let me know.
author_association: CONTRIBUTOR
user: login: charliemax5
created_at: 2025-04-22T19:35:38Z
body: Hey @Nik-09,
I just wanted to send you another message about your comments.

1.  Variable Names
    > Sorry, I didn't understand what these variable means. Can you rename these?

    > Ditto here and below, please make these variable names more meaningful.

    Could I just make a code comment explaining the names rather than refactoring the files?

2. Code Comments
    > Here and many of the code comments are not required. If you still think this can improve understanding, please provide 
     more context for the code comments.

    Should I try to reword the comments or should I just delete them?

3. Error Code
    > Even if the error code is not 400, we can still show the error message (error.error), right?

    When the error is raised as an InvalidInputException, doesn't that mean that it will always be a 400 Error? So, how would it be a code other than 400?

4. moderator_test.py
    > I would suggest splitting this test case into multiple tests, consisting of 1 test/scenario.

    would this fix the coverage problem? Or is it just for readability?

Please let me know as soon as you can.

Thank you!
author_association: CONTRIBUTOR
user: login: charliemax5
created_at: 2025-04-25T16:44:05Z
body: @Nik-09 @seanlip 
I have made changes addressed by the comments and all checks are passing. Please let me know if there's anything else I can do.

Thank you!
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-04-25T23:30:16Z
body: @charliemax5 If you would like reviewers to take a look at your PR, please make sure they are assigned to it, otherwise they'll miss it. See step 5 of the "essential checklist" in the PR description. Thanks.
author_association: MEMBER
user: login: charliemax5
created_at: 2025-04-29T00:26:11Z
body: @Nik-09 PTAL
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-29T00:26:25Z
body: Assigned reviewers: @Nik-09. Thank you!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-04-29T00:29:15Z
body: Unassigning @charliemax5 since a re-review was requested. @charliemax5, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: Nik-09
created_at: 2025-05-03T07:33:07Z
body: Hi @charliemax5, assign me once the PR is ready for review.
_(To assign reviewers, check the 5th checklist in the "Essential checklist" section in PR description.)_
Thanks!

author_association: MEMBER
user: login: charliemax5
created_at: 2025-05-04T16:18:40Z
body: @Nik-09 PTAL. Thank you!
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-04T16:18:50Z
body: Assigned reviewers: @Nik-09. Thank you!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-05-04T16:21:44Z
body: Unassigning @charliemax5 since a re-review was requested. @charliemax5, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-05-05T13:01:07Z
body: Unassigning @Nik-09 since the review is done.
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-05-05T13:01:10Z
body: Hi @charliemax5, it looks like some changes were requested on this pull request by @Nik-09. PTAL. Thanks!
author_association: NONE
user: login: charliemax5
created_at: 2025-05-07T19:26:45Z
body: @Nik-09 PTAL! Let me know if anything else needs to be changed. Thank you!
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-07T19:26:57Z
body: Assigned reviewers: @Nik-09. Thank you!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-05-07T19:29:49Z
body: Unassigning @charliemax5 since a re-review was requested. @charliemax5, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-05-10T06:46:03Z
body: Hi @charliemax5, this PR is ready to be merged. Please address any remaining comments prior to merging, and feel free to ask someone to merge your PR once the CI checks pass and you're happy with it. Thanks!
author_association: NONE
user: login: seanlip
created_at: 2025-05-12T17:41:30Z
body: @charliemax5 Please follow [these instructions](https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR) if CI checks fail on your PR, thanks.
author_association: MEMBER
user: login: charliemax5
created_at: 2025-05-12T22:10:30Z
body: @seanlip It seems that the test that is failing is a new bug in the code, per #22535.  If this is a bug, is there anything I can do to fix it? Do I just have to wait until someone fixes the bug? Please let me know.
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-05-17T19:53:03Z
body: @charliemax5 I think that bug is now fixed, so hopefully you can merge this now!
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-05-24T07:14:24Z
body: ✨ **Fantastic work @charliemax5!** Your very first PR to Oppia has been merged! 🎉🥳

You've just taken your first step into open-source, and we couldn’t be happier to have you onboard. 🙌
If you're feeling adventurous, why not dive into another issue and keep contributing? The community would love to see more from you! 🚀

For any support, feel free to reach out to the developer onboarding lead: @HardikGoyal2003. Happy coding! 👩‍💻👨‍💻
author_association: NONE