Repository: oppia/oppia

Issue:
Add a test that ensures correct behavior of the _create_change_list_from_suggestion() function.
For the _create_change_list_from_suggestion() function here:

```
https://github.com/oppia/oppia/blob/suggestions/core/domain/exp_services.py#L1470
```

(1) It would be nice to add a backend test that ensures that the change list for a suggestion that was accepted is the same as the change list for a suggestion that was made solely by the exploration creator.
(2) It would also be good to add a note to the docstring of the function along the lines of: "this function assumes that the suggestion has the form ..."



Discussion:
@seanlip I'm not sure how to capture the change list created when the change is made by the exploration editor. The change list in this case is created by the UI, I'm not sure how to emulate that in the backend.


Resolving patch:
diff --git a/core/controllers/editor.py b/core/controllers/editor.py
--- a/core/controllers/editor.py
+++ b/core/controllers/editor.py
@@ -16,6 +16,7 @@
 
 """Controllers for the editor view."""
 
+import datetime
 import imghdr
 import logging
 
@@ -42,6 +43,7 @@
 import utils
 
 current_user_services = models.Registry.import_current_user_services()
+(user_models,) = models.Registry.import_models([models.NAMES.user])
 
 # The frontend template for a new state. It is sent to the frontend when the
 # exploration editor page is first loaded, so that new states can be
@@ -260,11 +262,16 @@ class ExplorationHandler(EditorHandler):
 
     PAGE_NAME_FOR_CSRF = 'editor'
 
-    def _get_exploration_data(self, exploration_id, version=None):
+    def _get_exploration_data(
+            self, exploration_id, apply_draft=False, version=None):
         """Returns a description of the given exploration."""
         try:
-            exploration = exp_services.get_exploration_by_id(
-                exploration_id, version=version)
+            if apply_draft:
+                exploration = exp_services.get_exp_with_draft_applied(
+                    exploration_id, self.user_id)
+            else:
+                exploration = exp_services.get_exploration_by_id(
+                    exploration_id, version=version)
         except:
             raise self.PageNotFoundException
 
@@ -275,7 +282,15 @@ def _get_exploration_data(self, exploration_id, version=None):
                 stats_services.get_top_unresolved_answers_for_default_rule(
                     exploration_id, state_name))
             states[state_name] = state_dict
-
+        exp_user_data = user_models.ExplorationUserDataModel.get(
+            self.user_id, exploration_id)
+        draft_changes = (exp_user_data.draft_change_list if exp_user_data
+                         and exp_user_data.draft_change_list else None)
+        is_version_of_draft_valid = (
+            exp_services.is_version_of_draft_valid(
+                exploration_id, exp_user_data.draft_change_list_exp_version)
+            if exp_user_data and exp_user_data.draft_change_list_exp_version
+            else None)
         editor_dict = {
             'category': exploration.category,
             'exploration_id': exploration_id,
@@ -294,6 +309,8 @@ def _get_exploration_data(self, exploration_id, version=None):
             'tags': exploration.tags,
             'title': exploration.title,
             'version': exploration.version,
+            'is_version_of_draft_valid': is_version_of_draft_valid,
+            'draft_changes': draft_changes
         }
 
         return editor_dict
@@ -303,10 +320,14 @@ def get(self, exploration_id):
         if not rights_manager.Actor(self.user_id).can_view(
                 rights_manager.ACTIVITY_TYPE_EXPLORATION, exploration_id):
             raise self.PageNotFoundException
-
+        # 'apply_draft' and 'v'(version) are optional parameters because the
+        # exploration history tab also uses this handler, and these parameters
+        # are not used by that tab.
         version = self.request.get('v', default_value=None)
+        apply_draft = self.request.get('apply_draft', default_value=False)
         self.values.update(
-            self._get_exploration_data(exploration_id, version=version))
+            self._get_exploration_data(
+                exploration_id, apply_draft=apply_draft, version=version))
         self.render_json(self.values)
 
     @require_editor
@@ -924,3 +945,37 @@ class StartedTutorialEventHandler(EditorHandler):
     def post(self):
         """Handles GET requests."""
         user_services.record_user_started_state_editor_tutorial(self.user_id)
+
+
+class EditorAutosaveHandler(ExplorationHandler):
+    """Handles requests from the editor for draft autosave."""
+
+    @require_editor
+    def put(self, exploration_id):
+        """Handles PUT requests for draft updation."""
+        # Raise an Exception if the draft change list fails non-strict
+        # validation.
+        try:
+            change_list = self.payload.get('change_list')
+            version = self.payload.get('version')
+            exp_services.create_or_update_draft(
+                exploration_id, self.user_id, change_list, version,
+                datetime.datetime.utcnow())
+        except utils.ValidationError as e:
+            # If this exception is raised, let the user know that their changes
+            # have been auto discarded.
+            exp_services.discard_draft(exploration_id, self.user_id)
+            raise self.InvalidInputException(e)
+
+        # If the value passed here is False, have the user discard the draft
+        # changes. We save the draft to the datastore even if the version is
+        # invalid, so that it is available for recovery later.
+        self.render_json({
+            'is_version_of_draft_valid': exp_services.is_version_of_draft_valid(
+                exploration_id, version)})
+
+    @require_editor
+    def post(self, exploration_id):
+        """Handles POST request for discarding draft changes."""
+        exp_services.discard_draft(exploration_id, self.user_id)
+        self.render_json({})
diff --git a/core/domain/exp_services.py b/core/domain/exp_services.py
--- a/core/domain/exp_services.py
+++ b/core/domain/exp_services.py
@@ -1013,15 +1013,7 @@ def update_exploration(
     exploration = apply_change_list(exploration_id, change_list)
     _save_exploration(committer_id, exploration, commit_message, change_list)
 
-    # If there is an existing exploration draft for this user, clear it.
-    exp_user_data = user_models.ExplorationUserDataModel.get(
-        committer_id, exploration_id)
-    if exp_user_data:
-        exp_user_data.draft_change_list = None
-        exp_user_data.draft_change_list_last_updated = None
-        exp_user_data.draft_change_list_exp_version = None
-        exp_user_data.put()
-
+    discard_draft(exploration_id, committer_id)
     # Update summary of changed exploration.
     update_exploration_summary(exploration.id, committer_id)
     user_services.add_edited_exploration_id(committer_id, exploration.id)
@@ -1566,14 +1558,11 @@ def reject_suggestion(editor_id, thread_id, exploration_id):
         thread.put()
 
 
-def is_draft_version_valid(exp_id, user_id):
+def is_version_of_draft_valid(exp_id, version):
     """Checks if the draft version is the same as the latest version of the
     exploration."""
 
-    draft_version = user_models.ExplorationUserDataModel.get(
-        user_id, exp_id).draft_change_list_exp_version
-    exp_version = get_exploration_by_id(exp_id).version
-    return draft_version == exp_version
+    return get_exploration_by_id(exp_id).version == version
 
 
 def create_or_update_draft(
@@ -1584,7 +1573,6 @@ def create_or_update_draft(
     timestamp of the draft.
     The method assumes that a ExplorationUserDataModel object exists for the
     given user and exploration."""
-
     exp_user_data = user_models.ExplorationUserDataModel.get(user_id, exp_id)
     if (exp_user_data.draft_change_list and
             exp_user_data.draft_change_list_last_updated > current_datetime):
@@ -1602,7 +1590,22 @@ def get_exp_with_draft_applied(exp_id, user_id):
     apply it to the exploration."""
 
     exp_user_data = user_models.ExplorationUserDataModel.get(user_id, exp_id)
+    exploration = get_exploration_by_id(exp_id)
     return (
         apply_change_list(exp_id, exp_user_data.draft_change_list)
-        if exp_user_data.draft_change_list
-        else get_exploration_by_id(exp_id))
+        if exp_user_data and exp_user_data.draft_change_list and
+        is_version_of_draft_valid(
+            exp_id, exp_user_data.draft_change_list_exp_version)
+        else exploration)
+
+
+def discard_draft(exp_id, user_id):
+    """Discard the draft for the given user and exploration."""
+
+    exp_user_data = user_models.ExplorationUserDataModel.get(
+        user_id, exp_id)
+    if exp_user_data:
+        exp_user_data.draft_change_list = None
+        exp_user_data.draft_change_list_last_updated = None
+        exp_user_data.draft_change_list_exp_version = None
+        exp_user_data.put()
diff --git a/main.py b/main.py
--- a/main.py
+++ b/main.py
@@ -344,6 +344,9 @@ def ui_access_wrapper(self, *args, **kwargs):
     get_redirect_route(
         r'/createhandler/started_tutorial_event',
         editor.StartedTutorialEventHandler, 'started_tutorial_event_handler'),
+    get_redirect_route(
+        r'/createhandler/autosave_draft/<exploration_id>',
+        editor.EditorAutosaveHandler, 'editor_autosave_handler'),
 
     get_redirect_route(
         r'%s' % feconf.RECENT_COMMITS_DATA_URL,


Test patch:
diff --git a/core/controllers/editor_test.py b/core/controllers/editor_test.py
--- a/core/controllers/editor_test.py
+++ b/core/controllers/editor_test.py
@@ -14,6 +14,7 @@
 
 """Tests for the exploration editor page."""
 
+import datetime
 import os
 import StringIO
 import zipfile
@@ -26,9 +27,12 @@
 from core.domain import stats_domain
 from core.domain import rights_manager
 from core.domain import rule_domain
+from core.platform import models
 from core.tests import test_utils
 import feconf
 
+(user_models,) = models.Registry.import_models([models.NAMES.user])
+
 
 class BaseEditorControllerTest(test_utils.GenericTestBase):
 
@@ -1243,3 +1247,167 @@ def test_email_functionality_cannot_be_used_by_non_moderators(self):
                 expected_status_int=401)
 
             self.logout()
+
+
+class EditorAutosaveTest(BaseEditorControllerTest):
+    """Test the handling of editor autosave actions."""
+
+    EXP_ID1 = '1'
+    EXP_ID2 = '2'
+    EXP_ID3 = '3'
+    NEWER_DATETIME = datetime.datetime.strptime('2017-03-16', '%Y-%m-%d')
+    OLDER_DATETIME = datetime.datetime.strptime('2015-03-16', '%Y-%m-%d')
+    DRAFT_CHANGELIST = [{
+        'cmd': 'edit_exploration_property',
+        'property_name': 'title',
+        'new_value': 'Updated title'}]
+    NEW_CHANGELIST = [{
+        'cmd': 'edit_exploration_property',
+        'property_name': 'title',
+        'new_value': 'New title'}]
+    INVALID_CHANGELIST = [{
+        'cmd': 'edit_exploration_property',
+        'property_name': 'title',
+        'new_value': 1}]
+
+    def _create_explorations_for_tests(self):
+        self.save_new_valid_exploration(self.EXP_ID1, self.owner_id)
+        exploration = exp_services.get_exploration_by_id(self.EXP_ID1)
+        exploration.add_states(['State A'])
+        exploration.states['State A'].update_interaction_id('TextInput')
+        self.save_new_valid_exploration(self.EXP_ID2, self.owner_id)
+        self.save_new_valid_exploration(self.EXP_ID3, self.owner_id)
+
+    def _create_exp_user_data_model_objects_for_tests(self):
+        # Explorations with draft set.
+        user_models.ExplorationUserDataModel(
+            id='%s.%s' % (self.owner_id, self.EXP_ID1), user_id=self.owner_id,
+            exploration_id=self.EXP_ID1,
+            draft_change_list=self.DRAFT_CHANGELIST,
+            draft_change_list_last_updated=self.NEWER_DATETIME,
+            draft_change_list_exp_version=1).put()
+        user_models.ExplorationUserDataModel(
+            id='%s.%s' % (self.owner_id, self.EXP_ID2), user_id=self.owner_id,
+            exploration_id=self.EXP_ID2,
+            draft_change_list=self.DRAFT_CHANGELIST,
+            draft_change_list_last_updated=self.OLDER_DATETIME,
+            draft_change_list_exp_version=1).put()
+
+        # Exploration with no draft.
+        user_models.ExplorationUserDataModel(
+            id='%s.%s' % (self.owner_id, self.EXP_ID3), user_id=self.owner_id,
+            exploration_id=self.EXP_ID3).put()
+
+    def setUp(self):
+        super(EditorAutosaveTest, self).setUp()
+        self.login(self.OWNER_EMAIL)
+        self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
+        self._create_explorations_for_tests()
+        self._create_exp_user_data_model_objects_for_tests()
+
+        # Generate CSRF token.
+        response = self.testapp.get('/create/%s' % self.EXP_ID1)
+        self.csrf_token = self.get_csrf_token_from_response(response)
+
+    def _null_method(self, *args):
+        pass
+
+    def test_exploration_loaded_with_draft_applied(self):
+        response = self.get_json(
+            '/createhandler/data/%s' % self.EXP_ID2, {'apply_draft': True})
+        # Title updated because chanhe list was applied.
+        self.assertEqual(response['title'], 'Updated title')
+        self.assertTrue(response['is_version_of_draft_valid'])
+        # Draft changes passed to UI.
+        self.assertEqual(response['draft_changes'], self.DRAFT_CHANGELIST)
+
+    def test_exploration_loaded_without_draft_when_draft_version_invalid(self):
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.owner_id, self.EXP_ID2))
+        exp_user_data.draft_change_list_exp_version = 20
+        exp_user_data.put()
+        response = self.get_json(
+            '/createhandler/data/%s' % self.EXP_ID2, {'apply_draft': True})
+        # Title not updated because change list not applied.
+        self.assertEqual(response['title'], 'A title')
+        self.assertFalse(response['is_version_of_draft_valid'])
+        # Draft changes passed to UI even when version is invalid.
+        self.assertEqual(response['draft_changes'], self.DRAFT_CHANGELIST)
+
+    def test_exploration_loaded_without_draft_as_draft_does_not_exist(self):
+        response = self.get_json(
+            '/createhandler/data/%s' % self.EXP_ID3, {'apply_draft': True})
+        # Title not updated because change list not applied.
+        self.assertEqual(response['title'], 'A title')
+        self.assertIsNone(response['is_version_of_draft_valid'])
+        # Draft changes None.
+        self.assertIsNone(response['draft_changes'])
+
+    def test_draft_not_updated_because_newer_draft_exists(self):
+        payload = {
+            'change_list': self.NEW_CHANGELIST,
+            'version': 1,
+        }
+        response = self.put_json(
+            '/createhandler/autosave_draft/%s' % self.EXP_ID1, payload,
+            self.csrf_token)
+        # Check that draft change list hasn't been updated.
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.owner_id, self.EXP_ID1))
+        self.assertEqual(exp_user_data.draft_change_list, self.DRAFT_CHANGELIST)
+        self.assertTrue(response['is_version_of_draft_valid'])
+
+    def test_draft_not_updated_validation_error(self):
+        payload = {
+            'change_list': self.INVALID_CHANGELIST,
+            'version': 1,
+        }
+        response = self.put_json(
+            '/createhandler/autosave_draft/%s' % self.EXP_ID2, payload,
+            self.csrf_token, expect_errors=True, expected_status_int=400)
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.owner_id, self.EXP_ID2))
+        self.assertIsNone(exp_user_data.draft_change_list)
+        self.assertIsNone(exp_user_data.draft_change_list_last_updated)
+        self.assertIsNone(exp_user_data.draft_change_list_exp_version)
+        self.assertEqual(
+            response, {'code': 400,
+                       'error': 'Expected title to be a string, received 1'})
+
+    def test_draft_updated_version_valid(self):
+        payload = {
+            'change_list': self.NEW_CHANGELIST,
+            'version': 1,
+        }
+        response = self.put_json(
+            '/createhandler/autosave_draft/%s' % self.EXP_ID2, payload,
+            self.csrf_token)
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.owner_id, self.EXP_ID2))
+        self.assertEqual(exp_user_data.draft_change_list, self.NEW_CHANGELIST)
+        self.assertEqual(exp_user_data.draft_change_list_exp_version, 1)
+        self.assertTrue(response['is_version_of_draft_valid'])
+
+    def test_draft_updated_version_invalid(self):
+        payload = {
+            'change_list': self.NEW_CHANGELIST,
+            'version': 10,
+        }
+        response = self.put_json(
+            '/createhandler/autosave_draft/%s' % self.EXP_ID2, payload,
+            self.csrf_token)
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.owner_id, self.EXP_ID2))
+        self.assertEqual(exp_user_data.draft_change_list, self.NEW_CHANGELIST)
+        self.assertEqual(exp_user_data.draft_change_list_exp_version, 10)
+        self.assertFalse(response['is_version_of_draft_valid'])
+
+    def test_discard_draft(self):
+        self.post_json(
+            '/createhandler/autosave_draft/%s' % self.EXP_ID2, {},
+            self.csrf_token)
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.owner_id, self.EXP_ID2))
+        self.assertIsNone(exp_user_data.draft_change_list)
+        self.assertIsNone(exp_user_data.draft_change_list_last_updated)
+        self.assertIsNone(exp_user_data.draft_change_list_exp_version)
diff --git a/core/domain/exp_services_test.py b/core/domain/exp_services_test.py
--- a/core/domain/exp_services_test.py
+++ b/core/domain/exp_services_test.py
@@ -3123,15 +3123,25 @@ def test_draft_cleared_after_change_list_applied(self):
         self.assertIsNone(exp_user_data.draft_change_list_last_updated)
         self.assertIsNone(exp_user_data.draft_change_list_exp_version)
 
-    def test_draft_version_valid_true(self):
-        self.assertTrue(exp_services.is_draft_version_valid(
-            self.EXP_ID1, self.USER_ID))
+    def test_draft_version_valid_returns_true(self):
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.USER_ID, self.EXP_ID1))
+        self.assertTrue(exp_services.is_version_of_draft_valid(
+            self.EXP_ID1, exp_user_data.draft_change_list_exp_version))
+
+    def test_draft_version_valid_returns_false(self):
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.USER_ID, self.EXP_ID2))
+        self.assertFalse(exp_services.is_version_of_draft_valid(
+            self.EXP_ID2, exp_user_data.draft_change_list_exp_version))
 
-    def test_draft_version_valid_false(self):
-        self.assertFalse(exp_services.is_draft_version_valid(
-            self.EXP_ID2, self.USER_ID))
+    def test_draft_version_valid_when_no_draft_exists(self):
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.USER_ID, self.EXP_ID3))
+        self.assertFalse(exp_services.is_version_of_draft_valid(
+            self.EXP_ID3, exp_user_data.draft_change_list_exp_version))
 
-    def test_create_or_update_draft_older_draft_exists(self):
+    def test_create_or_update_draft_when_older_draft_exists(self):
         exp_services.create_or_update_draft(
             self.EXP_ID1, self.USER_ID, self.NEW_CHANGELIST, 5,
             self.NEWER_DATETIME)
@@ -3144,7 +3154,7 @@ def test_create_or_update_draft_older_draft_exists(self):
                          self.NEWER_DATETIME)
         self.assertEqual(exp_user_data.draft_change_list_exp_version, 5)
 
-    def test_create_or_update_draft_newer_draft_exists(self):
+    def test_create_or_update_draft_when_newer_draft_exists(self):
         exp_services.create_or_update_draft(
             self.EXP_ID1, self.USER_ID, self.NEW_CHANGELIST, 5,
             self.OLDER_DATETIME)
@@ -3157,7 +3167,7 @@ def test_create_or_update_draft_newer_draft_exists(self):
             exp_user_data.draft_change_list_last_updated, self.DATETIME)
         self.assertEqual(exp_user_data.draft_change_list_exp_version, 2)
 
-    def test_create_or_update_draft_draft_does_not_exist(self):
+    def test_create_or_update_draft_when_draft_does_not_exist(self):
         exp_services.create_or_update_draft(
             self.EXP_ID3, self.USER_ID, self.NEW_CHANGELIST, 5,
             self.NEWER_DATETIME)
@@ -3170,7 +3180,7 @@ def test_create_or_update_draft_draft_does_not_exist(self):
                          self.NEWER_DATETIME)
         self.assertEqual(exp_user_data.draft_change_list_exp_version, 5)
 
-    def test_get_exp_with_draft_applied_draft_exists(self):
+    def test_get_exp_with_draft_applied_when_draft_exists(self):
         exploration = exp_services.get_exploration_by_id(self.EXP_ID1)
         self.assertEqual(exploration.init_state.param_changes, [])
         updated_exp = exp_services.get_exp_with_draft_applied(
@@ -3182,9 +3192,24 @@ def test_get_exp_with_draft_applied_draft_exists(self):
             param_changes._customization_args,
             {'list_of_values': ['1', '2'], 'parse_with_jinja': False})
 
-    def test_get_exp_with_draft_applied_draft_does_not_exist(self):
+    def test_get_exp_with_draft_applied_when_draft_does_not_exist(self):
         exploration = exp_services.get_exploration_by_id(self.EXP_ID3)
         self.assertEqual(exploration.init_state.param_changes, [])
         updated_exp = exp_services.get_exp_with_draft_applied(
             self.EXP_ID3, self.USER_ID)
         self.assertEqual(updated_exp.init_state.param_changes, [])
+
+    def test_get_exp_with_draft_applied_when_draft_version_is_invalid(self):
+        exploration = exp_services.get_exploration_by_id(self.EXP_ID2)
+        self.assertEqual(exploration.init_state.param_changes, [])
+        updated_exp = exp_services.get_exp_with_draft_applied(
+            self.EXP_ID2, self.USER_ID)
+        self.assertEqual(updated_exp.init_state.param_changes, [])
+
+    def test_draft_discarded(self):
+        exp_services.discard_draft(self.EXP_ID1, self.USER_ID,)
+        exp_user_data = user_models.ExplorationUserDataModel.get_by_id(
+            '%s.%s' % (self.USER_ID, self.EXP_ID1))
+        self.assertIsNone(exp_user_data.draft_change_list)
+        self.assertIsNone(exp_user_data.draft_change_list_last_updated)
+        self.assertIsNone(exp_user_data.draft_change_list_exp_version)