Repository: oppia/oppia

Issue:
[BUG]: Unable to insert mathematical formula when editing translation suggestion
### Describe the bug

User is unable to insert mathematical formula using RTE components in Review translation dialogbox

### Steps To Reproduce

1. Login to www.oppiatestserver.org
2. Go to Contributor dashboard
3. Click on Review Translations
4. Select any topic and click on Translations
5. Select any chapter click on Review
6. Click on Edit under Translations
7. Select Insert mathematical formula
8. Enter x^2 + y^2
9. Click on Done and Update buttons
10. See in the textbox that the formula is not seen

### Expected Behavior

User should be able to update formulas into the translations using RTE components

### Screenshots/Videos

[Screen recording 2023-01-30 4.08.06 PM.webm](https://user-images.githubusercontent.com/101634267/215454504-13e7d9f1-d7c1-4c40-a4c7-228d2f4ace8d.webm)


### What device are you using?

Desktop

### Operating System

Other

### What browsers are you seeing the problem on?

Chrome

### Browser version

108.0.5359.111

### Additional context

_No response_


Discussion:
I've decided to take on this issue. So far, I'm getting the following 2 error messages every time I click "Done" on the LaTex editor modal:

```
POST http://localhost:8181/createhandler/imageupload/exploration_suggestions/13 404 (Not Found)

Could not find the page http://localhost:8181/createhandler/imageupload/exploration_suggestions/13
```

So I decided to start at the URL where the request is being sent from, then move towards the frontend, which eventually lead me to `<schema-based-custom-editor>`. However according to inspect element, `<schema-based-html-editor>` is rendered instead & `<schema-based-custom-editor>` isn't even in `/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html`. I'm still looking through the frontend, but I will also try to look into the backend when I'm finished.
Thanks @StephenYu2018! 

Just a thought, I think the POST 404 is the likely issue here and a good thing to investigate. Either the URL being constructed is wrong, or the URL is correct and the backend is not handling it properly. You can see https://github.com/oppia/oppia/wiki/Find-the-right-code-to-change#backend-changes-to-support-editing for an example of how to tie a URL to a handler (using main.py). I think that might be a good place to begin investigating from.
> I've decided to take on this issue. So far, I'm getting the following 2 error messages every time I click "Done" on the LaTex editor modal:
> 
> ```
> POST http://localhost:8181/createhandler/imageupload/exploration_suggestions/13 404 (Not Found)
> 
> Could not find the page http://localhost:8181/createhandler/imageupload/exploration_suggestions/13
> ```
> 
> So I decided to start at the URL where the request is being sent from, then move towards the frontend, which eventually lead me to `<schema-based-custom-editor>`. However according to inspect element, `<schema-based-html-editor>` is rendered instead & `<schema-based-custom-editor>` isn't even in `/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html`. I'm still looking through the frontend, but I will also try to look into the backend when I'm finished.

Hi @StephenYu2018, Thanks for taking on this issue. Feel free to ping me if you face any technical issue.
Also I think it's worth taking a look at this file (https://github.com/oppia/oppia/blob/develop/core/templates/services/rte-helper-modal.controller.ts#L200) especially line 200. This line is executed via event emitter when we click on done in rte editors.
@seanlip @Ash-2k3 Thank you for the advice!
I noticed that the 1st time I click the math expression editor button on the translation suggestion review modal, a copy of the math expression editor modal shows up behind the translation suggestion review modal. Clicking on the button a 2nd time will render a new copy of the math expression editor modal, this time in front of the translation suggestion review modal like it should. However, the first copy still remains rendered, behind the translation suggestion review modal, and we get 2 copies rendered at the same time according to Inspect Element. 

I tried to interact with the first copy by using Inspect Element to increase the z-index of the first copy of the math expression editor to the front. Then, I clicked "Done" after typing in "x^2 + y^2" into the `<textarea>` above. I still got the same backend error logs in the Console, so maybe this modal z-index issue is better off as a separate issue (assuming that a similar issue isn't opened at the moment).
So far, I've partially solved the issue. In `/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts`:

```ts
refreshActiveContributionState(): void {
  // ...
  this.contextService.setCustomEntityContext(
    AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS,
    this.activeSuggestion.target_id);
  // ...
}
```

The method `this.contextService.setCustomEntityContext` accepts an entity type and an entity id. However, `AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS` is an image context, not an entity type. So I replaced that argument with:

```ts
refreshActiveContributionState(): void {
  // ...
  this.contextService.setCustomEntityContext(
    AppConstants.ENTITY_TYPE.EXPLORATION,
    this.activeSuggestion.target_id);
  // ...
}
```

The following video shows that the POST and GET requests are properly sent and the math expression shows up in the edited translation suggestion, both before and after clicking the "Update" button. However, there's now an issue with submitting the review, since it can't find the asset (newly created math expression).

https://github.com/oppia/oppia/assets/117241994/d2e25d2a-8667-4624-bad2-d73fa8b76e8b

Tomorrow, I'll look more into the issue about the finalization of the suggestion review not being able to find the asset.
@seanlip @Ash-2k3 I manually tested the issue again. This time, I also checked the suggestion from the contributor's view (not just the reviewer's), and it seems like the math expression is being sent properly. 

https://github.com/oppia/oppia/assets/117241994/02b8179b-0c71-4e25-ac1e-f1220844c64e

However, there's now a few error messages that are misleading to both the reviewer and the original contributor, stating "Invalid Suggestion: Source asset does not exist". Not only that, but despite the reviewer accepting the suggestion, the contributor receives the suggestion feedback as rejected with revisions requested.

This error message is composed of `Invalid Suggestion ${errorMessage}` found in the translation suggestion review modal component TypeScript file, while `Source asset does not exist` exists in `/core/platform/storage/dev_mode_storage_services.py`. I'm wondering if these errors are only unique to the development build or not.
> I noticed that the 1st time I click the math expression editor button on the translation suggestion review modal, a copy of the math expression editor modal shows up behind the translation suggestion review modal. Clicking on the button a 2nd time will render a new copy of the math expression editor modal, this time in front of the translation suggestion review modal like it should. However, the first copy still remains rendered, behind the translation suggestion review modal, and we get 2 copies rendered at the same time according to Inspect Element.
> 
> I tried to interact with the first copy by using Inspect Element to increase the z-index of the first copy of the math expression editor to the front. Then, I clicked "Done" after typing in "x^2 + y^2" into the `<textarea>` above. I still got the same backend error logs in the Console, so maybe this modal z-index issue is better off as a separate issue (assuming that a similar issue isn't opened at the moment).

Hmm, I opened a similar issue [here](https://github.com/oppia/oppia/issues/17991) which has been marked as done apparently. Can you check with issue description if it matches what you say, if yes we might open it again.
@Ash-2k3 I would say it's very similar. 

The issue description doesn't mention that multiple copies of the same modal can be rendered simultaneously. Normally this wouldn't happen since the modal should appear in the front, covering the buttons to open more modals. However, since the first modal renders behind the original one, the user will probably click the open modal button again, which renders a 2nd copy (this time, in the front of all the other modals).
> 

@StephenYu2018 Are these result of your changes ?. If your changes are causing this then we might need to look for a different approach ?
> So far, I've partially solved the issue. In `/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts`:
> 
> ```ts
> refreshActiveContributionState(): void {
>   // ...
>   this.contextService.setCustomEntityContext(
>     AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS,
>     this.activeSuggestion.target_id);
>   // ...
> }
> ```
> 
> The method `this.contextService.setCustomEntityContext` accepts an entity type and an entity id. However, `AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS` is an image context, not an entity type. So I replaced that argument with:
> 
> ```ts
> refreshActiveContributionState(): void {
>   // ...
>   this.contextService.setCustomEntityContext(
>     AppConstants.ENTITY_TYPE.EXPLORATION,
>     this.activeSuggestion.target_id);
>   // ...
> }
> ```
> 
> The following video shows that the POST and GET requests are properly sent and the math expression shows up in the edited translation suggestion, both before and after clicking the "Update" button. However, there's now an issue with submitting the review, since it can't find the asset (newly created math expression).
> 
>  issue-17191-progress-2023-05-20.mp4 
> Tomorrow, I'll look more into the issue about the finalization of the suggestion review not being able to find the asset.

Hi @StephenYu2018, Thanks for digging into this.
Re your findings : As far as I know if it's the images we are talking about, both general images and math imgs, then the entity type has to be one of types of `IMAGE_CONTEXT`. Ideally, it should be question_suggestions if it is an image of question_editor and exploration_suggestions for translation_editor. Hope that makes sense :)
> > 
> 
> @StephenYu2018 Are these result of your changes ?. If your changes are causing this then we might need to look for a different approach ?

No, it was always like that even before I made changes.
I've actually run into the "modal overlapping" issue while submitting. It's quite annoying and I think it is definitely worth fixing properly as a separate issue. Perhaps we can give the "RTE component" modals a higher z-index than the default modals to try and avoid this.

For the math formula issue, I think there's a related discussion in the Contributor Experience team groupchat. @Ash-2k3 has prepared a [debugging doc](https://docs.google.com/document/d/1nUjuI_QCKFgmUZzbcy_D0EtqFFdqFcgdzw5-rLRfic4/edit#) and I suggested writing up what is actually happening in terms of the URLs where the images are stored and retrieved. I don't think this has been done yet, but I think it would be useful to ensure everyone is on the same page in terms of understanding what is actually happening. @Ash-2k3 when will you be able to do this?
@seanlip I have updated the docs with findings, related to the issue I am working on. Also the root cause of issue for which I prepared debugging doc, is not related to image retrieval or storing so I haven't mentioned about it.

However @seanlip, if you want me to dig into it, I will do so and make notes in docs. Also IIRC,  in dev we serve images and audios using AssetsDevHandler and in production these are served from GCS(I am not sure if it's relevant thou :), just a note).
@StephenYu2018 Any updates on this ?
@Ash-2k3 I did a git hard reset and did some `console.log` debugging throughout the codebase. 

First, I tried to `console.log` the error when `AssetsBackendApiService` receives the thrown error from the backend. The error was logged to the console just fine, so that told me that the program has reached the service and handles the error fine.

Next, I tried to `print` something like `'POST func called.'` inside `ImageUploadHandler.post` method at `/core/controllers/editor.py`. The statement `'POST func called.'` didn't show up in my terminal's console logs, so that told me that the program didn't start executing code in `ImageUploadHandler.post` yet.

I noticed that the `acl_decorators.can_edit_entity` decorator above `ImageUploadHandler.post`, and I decided to take a look. I think the issue is that within the decorator function, `feconf.IMAGE_CONTEXT_EXPLORATION_SUGGESTIONS` is not a valid key in the `functions` dictionary, which then raises a `PageNotFoundException` instead.

`/core/controllers/acl_decorators.py`

```py
def can_edit_entity(
    handler: Callable[..., _GenericHandlerFunctionReturnType]
) -> Callable[..., _GenericHandlerFunctionReturnType]:
    # ...

    @functools.wraps(handler)
    def test_can_edit_entity(
        self: _SelfBaseHandlerType,
        entity_type: str,
        entity_id: str,
        **kwargs: Any
    ) -> _GenericHandlerFunctionReturnType:
        # ...

        functions: (
            Dict[str, Callable[[str], _GenericHandlerFunctionReturnType]]
         ) = {
            feconf.ENTITY_TYPE_EXPLORATION: lambda entity_id: (
                can_edit_exploration(reduced_handler)(
                    self, entity_id, **kwargs)),
            feconf.ENTITY_TYPE_QUESTION: lambda entity_id: (
                can_edit_question(reduced_handler)(
                    self, entity_id, **kwargs)),
            feconf.ENTITY_TYPE_TOPIC: lambda entity_id: (
                can_edit_topic(reduced_handler)(
                    self, entity_id, **kwargs)),
            feconf.ENTITY_TYPE_SKILL: lambda entity_id: (
                can_edit_skill(reduced_handler)(
                    self, entity_id, **kwargs)),
            feconf.IMAGE_CONTEXT_QUESTION_SUGGESTIONS: lambda entity_id: (
                can_submit_images_to_questions(reduced_handler)(
                    self, entity_id, **kwargs)),
            # `feconf.IMAGE_CONTEXT_EXPLORATION_SUGGESTIONS` not here :(
            feconf.ENTITY_TYPE_STORY: lambda entity_id: (
                can_edit_story(reduced_handler)(
                    self, entity_id, **kwargs)),
            feconf.ENTITY_TYPE_BLOG_POST: lambda entity_id: (
                can_edit_blog_post(reduced_handler)(
                    self, entity_id, **kwargs))
        }
        if entity_type not in dict.keys(functions):
            raise self.PageNotFoundException
        return functions[entity_type](entity_id)
    
    return test_can_edit_entity
```
Maybe we can do something like this:

```py
            # ...
            feconf.IMAGE_CONTEXT_EXPLORATION_SUGGESTIONS: lambda entity_id: (
                can_submit_images_to_explorations(reduced_handler)(
                    self, entity_id, **kwargs)),
            # ...
```

And then write up the `can_submit_images_to_explorations` method within the same file, since the method doesn't exist.
Thanks @StephenYu2018! Before we go down this path, though, let's make sure that we are using the correct context. The tricky thing here is that, first, the image is being submitted as part of a question/exploration-content suggestion, and then that question/content gets accepted/rejected; if accepted, the changes are moved into the question/exploration proper. 

So the question becomes: in which namespace are the images under? I can think of two possible ways this might be designed:

- First, the images are under a suggestion namespace, and on acceptance of the suggestion, the images are copied from the suggestion namespace to the actual question/exploration namespace, and the references to those images in the HTML content are updated (when the suggestion is transferred over to the "actual" question/exploration).
- Or, the images start off under the actual question/exploration namespace when they are submitted. If the suggestion is accepted, then the images are in the right place and already being referred to correctly. If the suggestion is rejected then we just have extra images in the exploration namespace.

Would you be able to analyze the code and point out which of the above is currently happening? Then, after that, let's see if there are any holes in the current setup (for example, is the "updating of references" mentioned in the first point actually being done?)

> So far, I've partially solved the issue. In `/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts`:
> 
> ```ts
> refreshActiveContributionState(): void {
>   // ...
>   this.contextService.setCustomEntityContext(
>     AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS,
>     this.activeSuggestion.target_id);
>   // ...
> }
> ```
> 
> The method `this.contextService.setCustomEntityContext` accepts an entity type and an entity id. However, `AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS` is an image context, not an entity type. So I replaced that argument with:
> 
> ```ts
> refreshActiveContributionState(): void {
>   // ...
>   this.contextService.setCustomEntityContext(
>     AppConstants.ENTITY_TYPE.EXPLORATION,
>     this.activeSuggestion.target_id);
>   // ...
> }
> ```
> 
> The following video shows that the POST and GET requests are properly sent and the math expression shows up in the edited translation suggestion, both before and after clicking the "Update" button. However, there's now an issue with submitting the review, since it can't find the asset (newly created math expression).
> 
>  issue-17191-progress-2023-05-20.mp4 
> Tomorrow, I'll look more into the issue about the finalization of the suggestion review not being able to find the asset.

@StephenYu2018, based on your previous comment, it appears that changing the context to `ENTITY_TYPE.EXPLORATION` resolved the issue. However, I suspect that when the translation submitter uploads an image, we may be using the incorrect context. According to my understanding, the correct context in this case should be `IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS` as used in the suggestion-review-modal. Can you please confirm this ?

Furthermore, it might be beneficial to reach out to the contributor who made these changes in order to gain a more detailed understanding of their reasoning. It's possible that we may have overlooked something. What are your thoughts on this, @seanlip?
> Furthermore, it might be beneficial to reach out to the contributor who made these changes in order to gain a more detailed understanding of their reasoning. It's possible that we may have overlooked something. What are your thoughts on this, @seanlip?

Let's perhaps start by finding the PR that caused it. There may be clues in its discussion thread that might help.
> 

@seanlip I started from `ImageUploadHandler.post` method in `/core/controllers/editor.py`. The method makes a call to `save_original_and_compressed_versions_of_image` function in `/core/domain/fs_services.py` after validating the image and its filename and checking if the filename already exists. The function attempts to compress the original images (depending on a boolean value that determines whether its compressible or not) and then it commits the file to storage services in `GcsFileSystem.commit` method. But from here, I'm seeing details about the storage services that aren't really relevant to the transfer between namespaces. I can't seem to find where the transfer from the suggestion namespace to the question/exploration namespace occurs.
> > So far, I've partially solved the issue. In `/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts`:
> > ```ts
> > refreshActiveContributionState(): void {
> >   // ...
> >   this.contextService.setCustomEntityContext(
> >     AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS,
> >     this.activeSuggestion.target_id);
> >   // ...
> > }
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > The method `this.contextService.setCustomEntityContext` accepts an entity type and an entity id. However, `AppConstants.IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS` is an image context, not an entity type. So I replaced that argument with:
> > ```ts
> > refreshActiveContributionState(): void {
> >   // ...
> >   this.contextService.setCustomEntityContext(
> >     AppConstants.ENTITY_TYPE.EXPLORATION,
> >     this.activeSuggestion.target_id);
> >   // ...
> > }
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > The following video shows that the POST and GET requests are properly sent and the math expression shows up in the edited translation suggestion, both before and after clicking the "Update" button. However, there's now an issue with submitting the review, since it can't find the asset (newly created math expression).
> > issue-17191-progress-2023-05-20.mp4
> > Tomorrow, I'll look more into the issue about the finalization of the suggestion review not being able to find the asset.
> 
> @StephenYu2018, based on your previous comment, it appears that changing the context to `ENTITY_TYPE.EXPLORATION` resolved the issue. However, I suspect that when the translation submitter uploads an image, we may be using the incorrect context. According to my understanding, the correct context in this case should be `IMAGE_CONTEXT.EXPLORATION_SUGGESTIONS` as used in the suggestion-review-modal. Can you please confirm this ?
> 
> Furthermore, it might be beneficial to reach out to the contributor who made these changes in order to gain a more detailed understanding of their reasoning. It's possible that we may have overlooked something. What are your thoughts on this, @seanlip?

@Ash-2k3 I'm having trouble confirming the correct context through its unit test at `/core/templates/pages/contribution-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts`. The `ContextService` is never spied on nor used in `expect` statements in any test cases that call the `refreshActiveContributionState` component method.

Also which changes in specific are you referring to in your last paragraph?
@sagangwee @Lawful2002 @vojtechjelinek Is it possible to explain the reasoning behind adding lines 224-226 in `/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts`? Here's the link to the merged [commit](https://github.com/oppia/oppia/commit/ff417630fecfcc4ab0c0b404a40f8941b873bf93#diff-166f2738c8e16dadab8da9f3405b18bbfc55e0b95ec6d018211389f15cd1afaf) that you coauthored.
@StephenYu2018 I believe those lines are for setting the file path when copying files: https://github.com/oppia/oppia/blob/develop/core/templates/services/context.service.ts#L38-L41.
> Thanks @StephenYu2018! Before we go down this path, though, let's make sure that we are using the correct context. The tricky thing here is that, first, the image is being submitted as part of a question/exploration-content suggestion, and then that question/content gets accepted/rejected; if accepted, the changes are moved into the question/exploration proper.
> 
> So the question becomes: in which namespace are the images under? I can think of two possible ways this might be designed:
> 
>     * First, the images are under a suggestion namespace, and on acceptance of the suggestion, the images are copied from the suggestion namespace to the actual question/exploration namespace, and the references to those images in the HTML content are updated (when the suggestion is transferred over to the "actual" question/exploration).
> 
>     * Or, the images start off under the actual question/exploration namespace when they are submitted. If the suggestion is accepted, then the images are in the right place and already being referred to correctly. If the suggestion is rejected then we just have extra images in the exploration namespace.
> 
> 
> Would you be able to analyze the code and point out which of the above is currently happening? Then, after that, let's see if there are any holes in the current setup (for example, is the "updating of references" mentioned in the first point actually being done?)

@seanlip So I believe the program operates based off your first suggestion, based off my following code analysis:

When the reviewer clicks on the "Update" button after editing the suggestion, a PUT request gets sent to `/updatetranslationsuggestionhandler/exploration.13.WzE2ODYwNjkyODk3MjZdWzQyOTBd`, where `exploration.13.WzE2ODYwNjkyODk3MjZdWzQyOTBd` is the ID of the suggestion we want to update. That request's payload contains a `translation_html` key whose value contains the new HTML content of the translation after the reviewer's edits were made. The URL gets handled by `UpdateTranslationSuggestionHandler`, which retrieves the suggestion model with the matching ID, and updates its `translation_html` content.

Upon accepting the suggestion, a PUT request gets sent to `/suggestionactionhandler/exploration/13/exploration.13.WzE2ODYwNjkyODk3MjZdWzQyOTBd`, where `13` is the exploration ID we would like to target our changes to, and `exploration.13.WzE2ODYwNjkyODk3MjZdWzQyOTBd` being the suggestion ID mentioned in the last paragraph. That request's payload contains an `action` representing either `accept` or `reject` action of the suggestion (in this case, it's `accept`), a `review_message`, and a `commit_message`.  The URL gets handled by `SuggestionToExplorationActionHandler`, which checks the reviewer not being the author of the suggestion & the request's commit message being non-empty when accepting, and then delegates accepting the suggestion to `suggestion_services.accept_suggestion` function. `accept_suggestion` retrieves the suggestion model with the matching ID, validates the model, sets the suggestion's `status` and `reviewer_id`, and finally accepts the suggestion by calling `suggestion.accept` method. The `accept` method updates the exploration by calling `exp_services.update_exploration()` with the target exploration ID and a list of changes that need to be made to the exploration based off the accepted suggestion.

Resolving patch:
diff --git a/core/controllers/acl_decorators.py b/core/controllers/acl_decorators.py
--- a/core/controllers/acl_decorators.py
+++ b/core/controllers/acl_decorators.py
@@ -3212,7 +3212,10 @@ def test_can_submit_images_to_questions(
         if not self.user_id:
             raise base.UserFacingExceptions.NotLoggedInException
 
-        if role_services.ACTION_SUGGEST_CHANGES in self.user.actions:
+        if any(action in self.user.actions for action in [
+            role_services.ACTION_SUGGEST_CHANGES,
+            role_services.ACTION_EDIT_ANY_QUESTION
+        ]):
             return handler(self, skill_id, **kwargs)
         else:
             raise self.UnauthorizedUserException(
@@ -3221,6 +3224,52 @@ def test_can_submit_images_to_questions(
     return test_can_submit_images_to_questions
 
 
+def can_submit_images_to_explorations(
+    handler: Callable[..., _GenericHandlerFunctionReturnType]
+) -> Callable[..., _GenericHandlerFunctionReturnType]:
+    """Decorator to check whether the user can submit images to explorations.
+
+    Args:
+        handler: function. The function to be decorated.
+
+    Returns:
+        function. The newly decorated function that now also checks if
+        the user has permission to submit images to an exploration.
+    """
+
+    # Here we use type Any because this method can accept arbitrary number of
+    # arguments with different types.
+    @functools.wraps(handler)
+    def test_can_submit_images_to_explorations(
+        self: _SelfBaseHandlerType, target_id: str, **kwargs: Any
+    ) -> _GenericHandlerFunctionReturnType:
+        """Test to see if user can submit images to explorations.
+
+        Args:
+            target_id: str. The target exploration ID.
+            **kwargs: *. Keyword arguments.
+
+        Returns:
+            *. The return value of the decorated function.
+
+        Raises:
+            NotLoggedInException. The user is not logged in.
+            PageNotFoundException. The given page cannot be found.
+            UnauthorizedUserException. The user does not have the
+                credentials to edit the target exploration.
+        """
+        if not self.user_id:
+            raise base.UserFacingExceptions.NotLoggedInException
+
+        if role_services.ACTION_SUGGEST_CHANGES in self.user.actions:
+            return handler(self, target_id, **kwargs)
+        else:
+            raise self.UnauthorizedUserException(
+                'You do not have credentials to submit images to explorations.')
+
+    return test_can_submit_images_to_explorations
+
+
 def can_delete_skill(
     handler: Callable[..., _GenericHandlerFunctionReturnType]
 ) -> Callable[..., _GenericHandlerFunctionReturnType]:
@@ -4367,6 +4416,9 @@ def test_can_edit_entity(
             feconf.IMAGE_CONTEXT_QUESTION_SUGGESTIONS: lambda entity_id: (
                 can_submit_images_to_questions(reduced_handler)(
                     self, entity_id, **kwargs)),
+            feconf.IMAGE_CONTEXT_EXPLORATION_SUGGESTIONS: lambda entity_id: (
+                can_submit_images_to_explorations(reduced_handler)(
+                    self, entity_id, **kwargs)),
             feconf.ENTITY_TYPE_STORY: lambda entity_id: (
                 can_edit_story(reduced_handler)(
                     self, entity_id, **kwargs)),


Test patch:
diff --git a/core/controllers/acl_decorators_test.py b/core/controllers/acl_decorators_test.py
--- a/core/controllers/acl_decorators_test.py
+++ b/core/controllers/acl_decorators_test.py
@@ -6885,6 +6885,39 @@ def test_cannot_submit_images_to_questions_without_having_permissions(
                 ' images to questions.')
         self.logout()
 
+    def test_can_submit_images_to_explorations(self) -> None:
+        self.login(self.CURRICULUM_ADMIN_EMAIL)
+        with self.swap(self, 'testapp', self.mock_testapp):
+            response = self.get_json('/mock_edit_entity/%s/%s' % (
+                feconf.IMAGE_CONTEXT_EXPLORATION_SUGGESTIONS,
+                self.published_exp_id))
+            self.assertEqual(response['entity_id'], self.published_exp_id)
+            self.assertEqual(response['entity_type'], 'exploration_suggestions')
+        self.logout()
+
+    def test_unauthenticated_users_cannot_submit_images_to_explorations(
+        self
+    ) -> None:
+        with self.swap(self, 'testapp', self.mock_testapp):
+            self.get_json('/mock_edit_entity/%s/%s' % (
+                feconf.IMAGE_CONTEXT_EXPLORATION_SUGGESTIONS,
+                self.published_exp_id),
+                expected_status_int=401)
+
+    def test_cannot_submit_images_to_explorations_without_having_permissions(
+        self
+    ) -> None:
+        self.login(self.user_email)
+        with self.swap(self, 'testapp', self.mock_testapp):
+            response = self.get_json('/mock_edit_entity/%s/%s' % (
+                feconf.IMAGE_CONTEXT_EXPLORATION_SUGGESTIONS,
+                self.published_exp_id),
+                expected_status_int=401)
+            self.assertEqual(
+                response['error'], 'You do not have credentials to submit'
+                ' images to explorations.')
+        self.logout()
+
     def test_can_edit_blog_post(self) -> None:
         self.login(self.BLOG_ADMIN_EMAIL)
         blog_admin_id = (