Repository: wagtail/wagtail

Issue:
Elasticsearch backend indexes draft updates of existing pages
### Issue Summary

When saving a draft version of an existing live page the Elasticsearch backend reindexes the page with the draft content. The reindexed content will potentially cause frontend search results to include the page if the search query matches the draft content.

I'm using the following search query in my view:

   search_results = Page.objects.live().search(search_query)

New content that is saved as draft is not an issue since the live() filter excludes it.


### Steps to Reproduce

1. Edit an indexed field of an existing published page
2. Insert a unique term in the indexed field
3. Click 'Save Draft'
4. On the fontend search for the unique term.
5. The editted page will be returned in the results

I can see the value of having the draft content indexed on the Wagtail backend but since the frontend shares the same index, that can be a problem.

### Technical details

* Python version: 3.5.2.
* Django version: 1.10.4.
* Wagtail version: 1.8.
* Elasticsearch: 5



Discussion:
Thanks for the report @nfletton!

It looks like this is happening because the `Page.save_revision()` method [performs a save of the page model](https://github.com/wagtail/wagtail/blob/0e9c255b034ce4bdef5b265021833790fd813ed9/wagtail/wagtailcore/models.py#L710) in order to update the `latest_revision_created_at` and `has_unpublished_changes` fields, which in turn triggers [the post_save signal handler](https://github.com/wagtail/wagtail/blob/0e9c255b034ce4bdef5b265021833790fd813ed9/wagtail/wagtailsearch/signal_handlers.py#L8) to reindex the page.

The page instance received by the signal handler is the draft version, and so it goes ahead and indexes that version, even though the corresponding `save` didn't actually write the page content to the database.

The post_save signal [has access to the `update_fields` parameter](https://docs.djangoproject.com/en/1.10/ref/signals/#post-save), so a possible fix would be to check `update_fields is not None` and if so, fetch a fresh copy of the page record from the DB to be indexed instead of the draft one. (If we wanted to be really clever, we could compare `update_fields` against the model's `search_fields` and skip the operation entirely if there's nothing new to be indexed - but that's probably overkill...)

Resolving patch:
diff --git a/wagtail/wagtailsearch/signal_handlers.py b/wagtail/wagtailsearch/signal_handlers.py
--- a/wagtail/wagtailsearch/signal_handlers.py
+++ b/wagtail/wagtailsearch/signal_handlers.py
@@ -5,7 +5,13 @@
 from wagtail.wagtailsearch import index
 
 
-def post_save_signal_handler(instance, **kwargs):
+def post_save_signal_handler(instance, update_fields=None, **kwargs):
+    if update_fields is not None:
+        # fetch a fresh copy of instance from the database to ensure
+        # that we're not indexing any of the unsaved data contained in
+        # the fields that were not passed in update_fields
+        instance = type(instance).objects.get(pk=instance.pk)
+
     index.insert_or_update_object(instance)
 
 


Test patch:
diff --git a/wagtail/wagtailsearch/tests/test_index_functions.py b/wagtail/wagtailsearch/tests/test_index_functions.py
--- a/wagtail/wagtailsearch/tests/test_index_functions.py
+++ b/wagtail/wagtailsearch/tests/test_index_functions.py
@@ -56,12 +56,16 @@ def test_blocks_not_in_indexed_objects(self):
 class TestInsertOrUpdateObject(TestCase, WagtailTestUtils):
     def test_inserts_object(self, backend):
         obj = models.SearchTest.objects.create(title="Test")
+        backend().reset_mock()
+
         index.insert_or_update_object(obj)
 
         backend().add.assert_called_with(obj)
 
     def test_doesnt_insert_unsaved_object(self, backend):
         obj = models.SearchTest(title="Test")
+        backend().reset_mock()
+
         index.insert_or_update_object(obj)
 
         self.assertFalse(backend().add.mock_calls)
@@ -72,6 +76,9 @@ def test_converts_to_specific_page(self, backend):
 
         # Convert page into a generic "Page" object and add it into the index
         unspecific_page = page.page_ptr
+
+        backend().reset_mock()
+
         index.insert_or_update_object(unspecific_page)
 
         # It should be automatically converted back to the specific version
@@ -81,6 +88,7 @@ def test_catches_index_error(self, backend):
         obj = models.SearchTest.objects.create(title="Test")
 
         backend().add.side_effect = ValueError("Test")
+        backend().reset_mock()
 
         with self.assertLogs('wagtail.search.index', level='ERROR') as cm:
             index.insert_or_update_object(obj)
@@ -100,18 +108,23 @@ def test_catches_index_error(self, backend):
 class TestRemoveObject(TestCase, WagtailTestUtils):
     def test_removes_object(self, backend):
         obj = models.SearchTest.objects.create(title="Test")
+        backend().reset_mock()
+
         index.remove_object(obj)
 
-        backend().add.assert_called_with(obj)
+        backend().delete.assert_called_with(obj)
 
     def test_removes_unsaved_object(self, backend):
         obj = models.SearchTest(title="Test")
+        backend().reset_mock()
+
         index.remove_object(obj)
 
         backend().delete.assert_called_with(obj)
 
     def test_catches_index_error(self, backend):
         obj = models.SearchTest.objects.create(title="Test")
+        backend().reset_mock()
 
         backend().delete.side_effect = ValueError("Test")
 
@@ -122,3 +135,47 @@ def test_catches_index_error(self, backend):
         self.assertIn("Exception raised while deleting <SearchTest: Test> from the 'default' search backend", cm.output[0])
         self.assertIn("Traceback (most recent call last):", cm.output[0])
         self.assertIn("ValueError: Test", cm.output[0])
+
+
+@mock.patch('wagtail.wagtailsearch.tests.DummySearchBackend', create=True)
+@override_settings(WAGTAILSEARCH_BACKENDS={
+    'default': {
+        'BACKEND': 'wagtail.wagtailsearch.tests.DummySearchBackend'
+    }
+})
+class TestSignalHandlers(TestCase, WagtailTestUtils):
+    def test_index_on_create(self, backend):
+        backend().reset_mock()
+        obj = models.SearchTest.objects.create(title="Test", content="This is the content")
+        backend().add.assert_called_with(obj)
+
+    def test_index_on_update(self, backend):
+        obj = models.SearchTest.objects.create(title="Test", content="This is the content")
+
+        backend().reset_mock()
+        obj.title = "Updated test"
+        obj.save()
+
+        backend().add.assert_called_once()
+        indexed_object = backend().add.call_args[0][0]
+        self.assertEqual(indexed_object.title, "Updated test")
+
+    def test_index_on_delete(self, backend):
+        obj = models.SearchTest.objects.create(title="Test", content="This is the content")
+
+        backend().reset_mock()
+        obj.delete()
+        backend().delete.assert_called_with(obj)
+
+    def test_do_not_index_fields_omitted_from_update_fields(self, backend):
+        obj = models.SearchTest.objects.create(title="Test", content="This is the original content")
+
+        backend().reset_mock()
+        obj.title = "Updated test"
+        obj.content = "This is the updated content"
+        obj.save(update_fields=['title'])
+
+        backend().add.assert_called_once()
+        indexed_object = backend().add.call_args[0][0]
+        self.assertEqual(indexed_object.title, "Updated test")
+        self.assertEqual(indexed_object.content, "This is the original content")