Repository: oppia/oppia

Issue:
Ensure that all image RTE components have caption fields.
A "caption" field for images was introduced in #2104 but it seems like the correct backend changes were not introduced. In particular there should have been an exploration migration that checked each image RTE component and added a "caption is empty string" attribute if needed.

The aim of this issue is to write that migration, so that we can ensure that all explorations are up to date and have the correct fields.

/cc @bansalnitish @ishucr7 
Ensure that all image RTE components have caption fields.
A "caption" field for images was introduced in #2104 but it seems like the correct backend changes were not introduced. In particular there should have been an exploration migration that checked each image RTE component and added a "caption is empty string" attribute if needed.

The aim of this issue is to write that migration, so that we can ensure that all explorations are up to date and have the correct fields.

/cc @bansalnitish @ishucr7 


Discussion:
Hi @ishucr7, are you going to work on this, if not I can take over.

The workflow for the vN to vN+1 conversion function would be as follows:

- Iterate through all html strings in exploration states (similar to the approach here: https://github.com/oppia/oppia/blob/develop/core/domain/exp_domain.py#L3458)
- Parse the html using BeautifulSoup to obtain image tags (or use html_cleaner.get_rte_components). It would be easier to use BeautifulSoup since we need to edit the html
- Check for the presence of `caption-with-value` attribute in tag.attrs.
- If the attribute is absent, add it and store it in the html content after updating the html strings.

You can define a function in `html_cleaner.py` which does this for any html string/list of html strings.

Does this sound good @seanlip  @ishucr7?
Yes that's basically right, thanks @bansalnitish. One preemptive catch: make sure that the value of the attribute is properly escaped. 
Yes sure, I'll take care of that, learnt a lot about it in my migration PR for TextAngular :smile:.

Will take over this, just waiting for @ishucr7's confirmation!

Thanks!
Thanks, @bansalnitish! If you're up for it, I'd say just go for it, since time is of the essence here. 
Sure, will be submitting a PR ASAP!
Hi all. I am currently busy with other PR, and we have less time for the next release. Thanks for taking it up.
Hi @ishucr7, are you going to work on this, if not I can take over.

The workflow for the vN to vN+1 conversion function would be as follows:

- Iterate through all html strings in exploration states (similar to the approach here: https://github.com/oppia/oppia/blob/develop/core/domain/exp_domain.py#L3458)
- Parse the html using BeautifulSoup to obtain image tags (or use html_cleaner.get_rte_components). It would be easier to use BeautifulSoup since we need to edit the html
- Check for the presence of `caption-with-value` attribute in tag.attrs.
- If the attribute is absent, add it and store it in the html content after updating the html strings.

You can define a function in `html_cleaner.py` which does this for any html string/list of html strings.

Does this sound good @seanlip  @ishucr7?
Yes that's basically right, thanks @bansalnitish. One preemptive catch: make sure that the value of the attribute is properly escaped. 
Yes sure, I'll take care of that, learnt a lot about it in my migration PR for TextAngular :smile:.

Will take over this, just waiting for @ishucr7's confirmation!

Thanks!
Thanks, @bansalnitish! If you're up for it, I'd say just go for it, since time is of the essence here. 
Sure, will be submitting a PR ASAP!
Hi all. I am currently busy with other PR, and we have less time for the next release. Thanks for taking it up.

Resolving patch:
diff --git a/core/domain/exp_domain.py b/core/domain/exp_domain.py
--- a/core/domain/exp_domain.py
+++ b/core/domain/exp_domain.py
@@ -1776,6 +1776,63 @@ def create_default_state(
                 default_dest_state_name),
             feconf.DEFAULT_CONTENT_IDS_TO_AUDIO_TRANSLATIONS)
 
+    @classmethod
+    def convert_html_fields_in_state(cls, state_dict, conversion_fn):
+        """Applies a conversion function on all the html strings in a state
+        to migrate them to a desired state.
+
+        Args:
+            state_dict: dict. The dict representation of State object.
+            conversion_fn: function. The conversion function to be applied on
+                the states_dict.
+
+        Returns:
+            dict. The converted state_dict.
+        """
+        state_dict['content']['html'] = (
+            conversion_fn(state_dict['content']['html']))
+        if state_dict['interaction']['default_outcome']:
+            interaction_feedback_html = state_dict[
+                'interaction']['default_outcome']['feedback']['html']
+            state_dict['interaction']['default_outcome']['feedback'][
+                'html'] = conversion_fn(interaction_feedback_html)
+
+        for answer_group_index, answer_group in enumerate(
+                state_dict['interaction']['answer_groups']):
+            answer_group_html = answer_group['outcome']['feedback']['html']
+            state_dict['interaction']['answer_groups'][
+                answer_group_index]['outcome']['feedback']['html'] = (
+                    conversion_fn(answer_group_html))
+            if state_dict['interaction']['id'] == 'ItemSelectionInput':
+                for rule_spec_index, rule_spec in enumerate(
+                        answer_group['rule_specs']):
+                    for x_index, x in enumerate(rule_spec['inputs']['x']):
+                        state_dict['interaction']['answer_groups'][
+                            answer_group_index]['rule_specs'][
+                                rule_spec_index]['inputs']['x'][x_index] = (
+                                    conversion_fn(x))
+        for hint_index, hint in enumerate(
+                state_dict['interaction']['hints']):
+            hint_html = hint['hint_content']['html']
+            state_dict['interaction']['hints'][hint_index][
+                'hint_content']['html'] = conversion_fn(hint_html)
+
+        if state_dict['interaction']['solution']:
+            solution_html = state_dict[
+                'interaction']['solution']['explanation']['html']
+            state_dict['interaction']['solution']['explanation']['html'] = (
+                conversion_fn(solution_html))
+
+        if state_dict['interaction']['id'] in (
+                'ItemSelectionInput', 'MultipleChoiceInput'):
+            for value_index, value in enumerate(
+                    state_dict['interaction']['customization_args'][
+                        'choices']['value']):
+                state_dict['interaction']['customization_args'][
+                    'choices']['value'][value_index] = conversion_fn(value)
+
+        return state_dict
+
 
 class ExplorationVersionsDiff(object):
     """Domain object for the difference between two versions of an Oppia
@@ -3467,54 +3524,26 @@ def _convert_states_v21_dict_to_v22_dict(cls, states_dict):
         Returns:
             dict. The converted states_dict.
         """
-        for state_dict in states_dict.values():
-            state_dict['content']['html'] = (
-                html_cleaner.convert_to_textangular(
-                    state_dict['content']['html']))
-            if state_dict['interaction']['default_outcome']:
-                interaction_feedback_html = state_dict[
-                    'interaction']['default_outcome']['feedback']['html']
-                state_dict['interaction']['default_outcome']['feedback'][
-                    'html'] = html_cleaner.convert_to_textangular(
-                        interaction_feedback_html)
-
-            for answer_group_index, answer_group in enumerate(
-                    state_dict['interaction']['answer_groups']):
-                answer_group_html = answer_group['outcome']['feedback']['html']
-                state_dict['interaction']['answer_groups'][
-                    answer_group_index]['outcome']['feedback']['html'] = (
-                        html_cleaner.convert_to_textangular(
-                            answer_group_html))
-                if state_dict['interaction']['id'] == 'ItemSelectionInput':
-                    for rule_spec_index, rule_spec in enumerate(
-                            answer_group['rule_specs']):
-                        for x_index, x in enumerate(rule_spec['inputs']['x']):
-                            state_dict['interaction']['answer_groups'][
-                                answer_group_index]['rule_specs'][
-                                    rule_spec_index]['inputs']['x'][x_index] = (
-                                        html_cleaner.convert_to_textangular(x))
-            for hint_index, hint in enumerate(
-                    state_dict['interaction']['hints']):
-                hint_html = hint['hint_content']['html']
-                state_dict['interaction']['hints'][hint_index][
-                    'hint_content']['html'] = (
-                        html_cleaner.convert_to_textangular(hint_html))
+        for key, state_dict in states_dict.iteritems():
+            states_dict[key] = State.convert_html_fields_in_state(
+                state_dict, html_cleaner.convert_to_textangular)
+        return states_dict
 
-            if state_dict['interaction']['solution']:
-                solution_html = state_dict[
-                    'interaction']['solution']['explanation']['html']
-                state_dict['interaction']['solution']['explanation']['html'] = (
-                    html_cleaner.convert_to_textangular(solution_html))
-
-            if state_dict['interaction']['id'] in (
-                    'ItemSelectionInput', 'MultipleChoiceInput'):
-                for value_index, value in enumerate(
-                        state_dict['interaction']['customization_args'][
-                            'choices']['value']):
-                    state_dict['interaction']['customization_args'][
-                        'choices']['value'][value_index] = (
-                            html_cleaner.convert_to_textangular(value))
+    @classmethod
+    def _convert_states_v22_dict_to_v23_dict(cls, states_dict):
+        """Converts from version 22 to 23. Version 23 ensures that all
+        all oppia-noninteractive-image tags have caption attribute.
+        Args:
+            states_dict: dict. A dict where each key-value pair represents,
+                respectively, a state name and a dict used to initialize a
+                State domain object.
 
+        Returns:
+            dict. The converted states_dict.
+        """
+        for key, state_dict in states_dict.iteritems():
+            states_dict[key] = State.convert_html_fields_in_state(
+                state_dict, html_cleaner.add_caption_attr_to_image)
         return states_dict
 
     @classmethod
@@ -3548,7 +3577,7 @@ def update_states_from_model(
     # incompatible changes are made to the exploration schema in the YAML
     # definitions, this version number must be changed and a migration process
     # put in place.
-    CURRENT_EXP_SCHEMA_VERSION = 27
+    CURRENT_EXP_SCHEMA_VERSION = 28
     LAST_UNTITLED_SCHEMA_VERSION = 9
 
     @classmethod
@@ -4053,6 +4082,20 @@ def _convert_v26_dict_to_v27_dict(cls, exploration_dict):
 
         return exploration_dict
 
+    @classmethod
+    def _convert_v27_dict_to_v28_dict(cls, exploration_dict):
+        """Converts a v27 exploration dict into a v28 exploration dict.
+
+        Adds caption attribute to all oppia-noninteractive-image tags.
+        """
+        exploration_dict['schema_version'] = 28
+
+        exploration_dict['states'] = cls._convert_states_v22_dict_to_v23_dict(
+            exploration_dict['states'])
+        exploration_dict['states_schema_version'] = 23
+
+        return exploration_dict
+
     @classmethod
     def _migrate_to_latest_yaml_version(
             cls, yaml_content, title=None, category=None):
@@ -4220,6 +4263,11 @@ def _migrate_to_latest_yaml_version(
                 exploration_dict)
             exploration_schema_version = 27
 
+        if exploration_schema_version == 27:
+            exploration_dict = cls._convert_v27_dict_to_v28_dict(
+                exploration_dict)
+            exploration_schema_version = 28
+
         return (exploration_dict, initial_schema_version)
 
     @classmethod
diff --git a/core/domain/html_cleaner.py b/core/domain/html_cleaner.py
--- a/core/domain/html_cleaner.py
+++ b/core/domain/html_cleaner.py
@@ -726,3 +726,24 @@ def _validate_soup_for_rte(soup, rte_format, err_dict):
             is_invalid = True
 
     return is_invalid
+
+
+def add_caption_attr_to_image(html_string):
+    """Adds caption attribute to all oppia-noninteractive-image tags.
+
+    Args:
+        html_string. str: HTML string in which the caption attribute is to be
+            added.
+
+    Returns:
+        str. Updated HTML string with the caption attribute for all
+            oppia-noninteractive-image tags.
+    """
+    soup = bs4.BeautifulSoup(html_string.encode('utf-8'), 'html.parser')
+
+    for image in soup.findAll('oppia-noninteractive-image'):
+        attrs = image.attrs
+        if 'caption-with-value' not in attrs:
+            image['caption-with-value'] = escape_html(json.dumps(''))
+
+    return unicode(soup)
diff --git a/feconf.py b/feconf.py
--- a/feconf.py
+++ b/feconf.py
@@ -157,7 +157,7 @@
 # incompatible changes are made to the states blob schema in the data store,
 # this version number must be changed and the exploration migration job
 # executed.
-CURRENT_EXPLORATION_STATES_SCHEMA_VERSION = 22
+CURRENT_EXPLORATION_STATES_SCHEMA_VERSION = 23
 
 # The current version of the all collection blob schemas (such as the nodes
 # structure within the Collection domain object). If any backward-incompatible


Test patch:
diff --git a/core/domain/exp_domain_test.py b/core/domain/exp_domain_test.py
--- a/core/domain/exp_domain_test.py
+++ b/core/domain/exp_domain_test.py
@@ -21,6 +21,7 @@
 
 from core.domain import exp_domain
 from core.domain import exp_services
+from core.domain import html_cleaner
 from core.domain import param_domain
 from core.platform import models
 from core.tests import test_utils
@@ -3282,7 +3283,116 @@ class SchemaMigrationUnitTests(test_utils.GenericTestBase):
 title: Title
 """)
 
-    _LATEST_YAML_CONTENT = YAML_CONTENT_V27
+    YAML_CONTENT_V28 = ("""author_notes: ''
+auto_tts_enabled: true
+blurb: ''
+category: Category
+correctness_feedback_enabled: false
+init_state_name: (untitled state)
+language_code: en
+objective: ''
+param_changes: []
+param_specs: {}
+schema_version: 28
+states:
+  (untitled state):
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: ''
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+      feedback_1: {}
+    interaction:
+      answer_groups:
+      - outcome:
+          dest: END
+          feedback:
+            content_id: feedback_1
+            html: <p>Correct!</p>
+          labelled_as_correct: false
+          missing_prerequisite_skill_id: null
+          param_changes: []
+          refresher_exploration_id: null
+        rule_specs:
+        - inputs:
+            x: InputString
+          rule_type: Equals
+        tagged_misconception_id: null
+        training_data: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: (untitled state)
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+  END:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: <p>Congratulations, you have finished!</p>
+    content_ids_to_audio_translations:
+      content: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        recommendedExplorationIds:
+          value: []
+      default_outcome: null
+      hints: []
+      id: EndExploration
+      solution: null
+    param_changes: []
+  New state:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: ''
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: END
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+states_schema_version: 23
+tags: []
+title: Title
+""")
+
+    _LATEST_YAML_CONTENT = YAML_CONTENT_V28
 
     def test_load_from_v1(self):
         """Test direct loading from a v1 yaml file."""
@@ -3446,9 +3556,15 @@ def test_load_from_v27(self):
             'eid', self.YAML_CONTENT_V27)
         self.assertEqual(exploration.to_yaml(), self._LATEST_YAML_CONTENT)
 
+    def test_load_from_v28(self):
+        """Test direct loading from a v28 yaml file."""
+        exploration = exp_domain.Exploration.from_yaml(
+            'eid', self.YAML_CONTENT_V28)
+        self.assertEqual(exploration.to_yaml(), self._LATEST_YAML_CONTENT)
+
 
-class RTEMigrationUnitTests(test_utils.GenericTestBase):
-    """Test content migration for RTE."""
+class HTMLMigrationUnitTests(test_utils.GenericTestBase):
+    """Test HTML migration."""
 
     YAML_CONTENT_V26_TEXTANGULAR = ("""author_notes: ''
 auto_tts_enabled: true
@@ -3654,7 +3770,7 @@ class RTEMigrationUnitTests(test_utils.GenericTestBase):
 """)
 
 # pylint: disable=line-too-long
-    YAML_CONTENT_V27_TEXTANGULAR = ("""author_notes: ''
+    YAML_CONTENT_V28_TEXTANGULAR = ("""author_notes: ''
 auto_tts_enabled: true
 blurb: ''
 category: category
@@ -3664,7 +3780,7 @@ class RTEMigrationUnitTests(test_utils.GenericTestBase):
 objective: ''
 param_changes: []
 param_specs: {}
-schema_version: 27
+schema_version: 28
 states:
   Introduction:
     classifier_model_id: null
@@ -3754,7 +3870,7 @@ class RTEMigrationUnitTests(test_utils.GenericTestBase):
           dest: state3
           feedback:
             content_id: outcome
-            html: \"<pre>Outcome2 \\nfor state2</pre>\"
+            html: "<pre>Outcome2 \\nfor state2</pre>"
           labelled_as_correct: false
           missing_prerequisite_skill_id: null
           param_changes: []
@@ -3769,8 +3885,8 @@ class RTEMigrationUnitTests(test_utils.GenericTestBase):
       customization_args:
         choices:
           value:
-          - <p>This is </p><p>value1 <br>for MultipleChoice</p>
-          - <p>This is value2 for <br>MultipleChoice</p>
+          - <p>This is </p><p>value1 <br/>for MultipleChoice</p>
+          - <p>This is value2 for <br/>MultipleChoice</p>
       default_outcome:
         dest: state2
         feedback:
@@ -3805,8 +3921,9 @@ class RTEMigrationUnitTests(test_utils.GenericTestBase):
           dest: state1
           feedback:
             content_id: outcome
-            html: <p>Here is the image1 <i><oppia-noninteractive-image filepath-with-value="amp;quot;startBlue.png&amp;quot;">
-              </oppia-noninteractive-image></i>Here is the image2 </p><p><oppia-noninteractive-image
+            html: <p>Here is the image1 <i><oppia-noninteractive-image caption-with-value="&amp;quot;&amp;quot;"
+              filepath-with-value="amp;quot;startBlue.png&amp;quot;"> </oppia-noninteractive-image></i>Here
+              is the image2 </p><p><oppia-noninteractive-image caption-with-value="&amp;quot;&amp;quot;"
               filepath-with-value=" amp;quot;startBlue.png&amp;quot;"> </oppia-noninteractive-image></p>
           labelled_as_correct: false
           missing_prerequisite_skill_id: null
@@ -3847,9 +3964,231 @@ class RTEMigrationUnitTests(test_utils.GenericTestBase):
       id: ItemSelectionInput
       solution: null
     param_changes: []
-states_schema_version: 22
+states_schema_version: 23
 tags: []
 title: title
+""")
+
+    YAML_CONTENT_V27_WITHOUT_IMAGE_CAPTION = ("""author_notes: ''
+auto_tts_enabled: true
+blurb: ''
+category: Category
+correctness_feedback_enabled: false
+init_state_name: (untitled state)
+language_code: en
+objective: ''
+param_changes: []
+param_specs: {}
+schema_version: 27
+states:
+  (untitled state):
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: <p><oppia-noninteractive-image filepath-with-value="&amp;quot;random.png
+            &amp;quot;"></oppia-noninteractive-image>Hello this
+            is test case to check image tag inside p tag</p>
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+      feedback_1: {}
+    interaction:
+      answer_groups:
+      - outcome:
+          dest: END
+          feedback:
+            content_id: feedback_1
+            html: <p>Correct!</p>
+          labelled_as_correct: false
+          missing_prerequisite_skill_id: null
+          param_changes: []
+          refresher_exploration_id: null
+        rule_specs:
+        - inputs:
+            x: InputString
+          rule_type: Equals
+        tagged_misconception_id: null
+        training_data: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: (untitled state)
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+  END:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: <p>Congratulations, you have finished!</p>
+    content_ids_to_audio_translations:
+      content: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        recommendedExplorationIds:
+          value: []
+      default_outcome: null
+      hints: []
+      id: EndExploration
+      solution: null
+    param_changes: []
+  New state:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: ''
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: END
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+states_schema_version: 22
+tags: []
+title: Title
+""")
+
+    YAML_CONTENT_V28_WITH_IMAGE_CAPTION = ("""author_notes: ''
+auto_tts_enabled: true
+blurb: ''
+category: Category
+correctness_feedback_enabled: false
+init_state_name: (untitled state)
+language_code: en
+objective: ''
+param_changes: []
+param_specs: {}
+schema_version: 28
+states:
+  (untitled state):
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: <p><oppia-noninteractive-image caption-with-value="&amp;quot;&amp;quot;"
+        filepath-with-value="&amp;quot;random.png &amp;quot;"></oppia-noninteractive-image>Hello
+        this is test case to check image tag inside p tag</p>
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+      feedback_1: {}
+    interaction:
+      answer_groups:
+      - outcome:
+          dest: END
+          feedback:
+            content_id: feedback_1
+            html: <p>Correct!</p>
+          labelled_as_correct: false
+          missing_prerequisite_skill_id: null
+          param_changes: []
+          refresher_exploration_id: null
+        rule_specs:
+        - inputs:
+            x: InputString
+          rule_type: Equals
+        tagged_misconception_id: null
+        training_data: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: (untitled state)
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+  END:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: <p>Congratulations, you have finished!</p>
+    content_ids_to_audio_translations:
+      content: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        recommendedExplorationIds:
+          value: []
+      default_outcome: null
+      hints: []
+      id: EndExploration
+      solution: null
+    param_changes: []
+  New state:
+    classifier_model_id: null
+    content:
+      content_id: content
+      html: ''
+    content_ids_to_audio_translations:
+      content: {}
+      default_outcome: {}
+    interaction:
+      answer_groups: []
+      confirmed_unclassified_answers: []
+      customization_args:
+        placeholder:
+          value: ''
+        rows:
+          value: 1
+      default_outcome:
+        dest: END
+        feedback:
+          content_id: default_outcome
+          html: ''
+        labelled_as_correct: false
+        missing_prerequisite_skill_id: null
+        param_changes: []
+        refresher_exploration_id: null
+      hints: []
+      id: TextInput
+      solution: null
+    param_changes: []
+states_schema_version: 23
+tags: []
+title: Title
 """)
 # pylint: enable=line-too-long
 
@@ -3858,7 +4197,14 @@ def test_load_from_v26_textangular(self):
         exploration = exp_domain.Exploration.from_yaml(
             'eid', self.YAML_CONTENT_V26_TEXTANGULAR)
         self.assertEqual(
-            exploration.to_yaml(), self.YAML_CONTENT_V27_TEXTANGULAR)
+            exploration.to_yaml(), self.YAML_CONTENT_V28_TEXTANGULAR)
+
+    def test_load_from_v27_without_image_caption(self):
+        """Test direct loading from a v27 yaml file."""
+        exploration = exp_domain.Exploration.from_yaml(
+            'eid', self.YAML_CONTENT_V27_WITHOUT_IMAGE_CAPTION)
+        self.assertEqual(
+            exploration.to_yaml(), self.YAML_CONTENT_V28_WITH_IMAGE_CAPTION)
 
 
 class ConversionUnitTests(test_utils.GenericTestBase):
@@ -4069,6 +4415,117 @@ def test_state_operations(self):
         exploration.delete_state('END')
         self.assertNotIn('END', exploration.states)
 
+    def test_convert_html_fields_in_state(self):
+        """Test conversion of html strings in state."""
+        state_dict = {
+            'content': {
+                'content_id': 'content', 'html': 'Hello!'
+            },
+            'param_changes': [],
+            'content_ids_to_audio_translations': {'content': {}},
+            'classifier_model_id': None,
+            'interaction': {
+                'solution': None,
+                'answer_groups': [],
+                'default_outcome': {
+                    'param_changes': [], 'feedback': {
+                        'content_id': 'default_outcome', 'html': (
+                            '<p><oppia-noninteractive-image filepath'
+                            '-with-value="&amp;quot;random.png&amp;'
+                            'quot;"></oppia-noninteractive-image>'
+                            'Hello this is test case to check '
+                            'image tag inside p tag</p>'
+                        )
+                    },
+                    'dest': 'Introduction',
+                    'refresher_exploration_id': None,
+                    'missing_prerequisite_skill_id': None,
+                    'labelled_as_correct': False
+                },
+                'customization_args': {},
+                'confirmed_unclassified_answers': [],
+                'id': None,
+                'hints': []
+            }
+        }
+
+        state_dict_in_textangular = {
+            'content': {
+                'content_id': 'content', 'html': '<p>Hello!</p>'
+            },
+            'param_changes': [],
+            'content_ids_to_audio_translations': {'content': {}},
+            'classifier_model_id': None,
+            'interaction': {
+                'solution': None,
+                'answer_groups': [],
+                'default_outcome': {
+                    'param_changes': [], 'feedback': {
+                        'content_id': 'default_outcome', 'html': (
+                            '<p><oppia-noninteractive-image filepath'
+                            '-with-value="&amp;quot;random.png&amp;'
+                            'quot;"></oppia-noninteractive-image>'
+                            'Hello this is test case to check '
+                            'image tag inside p tag</p>'
+                        )
+                    },
+                    'dest': 'Introduction',
+                    'refresher_exploration_id': None,
+                    'missing_prerequisite_skill_id': None,
+                    'labelled_as_correct': False
+                },
+                'customization_args': {},
+                'confirmed_unclassified_answers': [],
+                'id': None,
+                'hints': []
+            }
+        }
+
+        state_dict_with_image_caption = {
+            'content': {
+                'content_id': 'content', 'html': '<p>Hello!</p>'
+            },
+            'param_changes': [],
+            'content_ids_to_audio_translations': {'content': {}},
+            'classifier_model_id': None,
+            'interaction': {
+                'solution': None,
+                'answer_groups': [],
+                'default_outcome': {
+                    'param_changes': [], 'feedback': {
+                        'content_id': 'default_outcome', 'html': (
+                            '<p><oppia-noninteractive-image caption-'
+                            'with-value="&amp;quot;&amp;quot;" filepath'
+                            '-with-value="&amp;quot;random.png&amp;'
+                            'quot;"></oppia-noninteractive-image>'
+                            'Hello this is test case to check '
+                            'image tag inside p tag</p>'
+                        )
+                    },
+                    'dest': 'Introduction',
+                    'refresher_exploration_id': None,
+                    'missing_prerequisite_skill_id': None,
+                    'labelled_as_correct': False
+                },
+                'customization_args': {},
+                'confirmed_unclassified_answers': [],
+                'id': None,
+                'hints': []
+            }
+        }
+
+        self.assertEqual(
+            exp_domain.State.convert_html_fields_in_state(
+                state_dict,
+                html_cleaner.convert_to_textangular),
+            state_dict_in_textangular)
+
+        self.assertEqual(
+            exp_domain.State.convert_html_fields_in_state(
+                state_dict,
+                html_cleaner.add_caption_attr_to_image),
+            state_dict_with_image_caption)
+
 
 class StateIdMappingTests(test_utils.GenericTestBase):
     """Tests for StateIdMapping domain class."""
diff --git a/core/domain/html_cleaner_test.py b/core/domain/html_cleaner_test.py
--- a/core/domain/html_cleaner_test.py
+++ b/core/domain/html_cleaner_test.py
@@ -608,12 +608,12 @@ def test_convert_to_ckeditor(self):
             )
         }, {
             'html_content': (
-                '<p><oppia-noninteractive-image filepath-with-value="amp;quot;'
+                '<p><oppia-noninteractive-image filepath-with-value="&amp;quot;'
                 'random.png&amp;quot;"></oppia-noninteractive-image>Hello this '
                 'is test case to check image tag inside p tag</p>'
             ),
             'expected_output': (
-                '<oppia-noninteractive-image filepath-with-value="amp;quot;'
+                '<oppia-noninteractive-image filepath-with-value="&amp;quot;'
                 'random.png&amp;quot;"></oppia-noninteractive-image><p>Hello '
                 'this is test case to check image tag inside p tag</p>'
             )
@@ -642,13 +642,13 @@ def test_convert_to_ckeditor(self):
         }, {
             'html_content': (
                 '<pre>Hello this is <b> testing '
-                '<oppia-noninteractive-image filepath-with-value="amp;quot;'
+                '<oppia-noninteractive-image filepath-with-value="&amp;quot;'
                 'random.png&amp;quot;"></oppia-noninteractive-image> in '
                 '</b>progress</pre>'
             ),
             'expected_output': (
                 '<pre>Hello this is <strong> testing </strong></pre>'
-                '<oppia-noninteractive-image filepath-with-value="amp;quot;'
+                '<oppia-noninteractive-image filepath-with-value="&amp;quot;'
                 'random.png&amp;quot;"></oppia-noninteractive-image><pre>'
                 '<strong> in </strong>progress</pre>'
             )
@@ -667,3 +667,41 @@ def test_convert_to_ckeditor(self):
             self.assertEqual(
                 test_case['expected_output'],
                 html_cleaner.convert_to_ckeditor(test_case['html_content']))
+
+    def test_add_caption_to_image(self):
+        test_cases = [{
+            'html_content': (
+                '<p><oppia-noninteractive-image filepath-with-value="&amp;quot;'
+                'random.png&amp;quot;"></oppia-noninteractive-image>Hello this '
+                'is test case to check that caption attribute is added to '
+                'image tags if it is missing.</p>'
+            ),
+            'expected_output': (
+                '<p><oppia-noninteractive-image caption-with-value="&amp;quot;'
+                '&amp;quot;" filepath-with-value="&amp;quot;random.png&amp;'
+                'quot;"></oppia-noninteractive-image>Hello this '
+                'is test case to check that caption attribute is added to '
+                'image tags if it is missing.</p>'
+            )
+        }, {
+            'html_content': (
+                '<p><oppia-noninteractive-image caption-with-value="&amp;quot;'
+                'abc&amp;quot;" filepath-with-value="&amp;quot;'
+                'random.png&amp;quot;"></oppia-noninteractive-image>Hello this '
+                'is test case to check that image tags that already have '
+                'caption attribute are not changed.</p>'
+            ),
+            'expected_output': (
+                '<p><oppia-noninteractive-image caption-with-value="&amp;quot;'
+                'abc&amp;quot;" filepath-with-value="&amp;quot;'
+                'random.png&amp;quot;"></oppia-noninteractive-image>Hello this '
+                'is test case to check that image tags that already have '
+                'caption attribute are not changed.</p>'
+            )
+        }]
+
+        for test_case in test_cases:
+            self.assertEqual(
+                html_cleaner.add_caption_attr_to_image(
+                    test_case['html_content']),
+                test_case['expected_output'])
diff --git a/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip b/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip
Binary files a/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip and b/core/tests/data/oppia-ThetitleforZIPdownloadhandlertest!-v2-gold.zip differ