Repository: wagtail/wagtail

Issue:
'ExtractYear' object has no attribute 'target'
### Issue Summary
Calling `search` queryset method on querysets that are filtered by exact year of a date throws AttributeError. This happens not only with `__year` but other exact matches also, e.g `__month`, `__day`. 

### Steps to Reproduce
1. `wagtail start wagtail-test`
2. `python manage shell`
3. `from wagtail.core.models import Page`
4. `Page.objects.filter(last_published_at__year=2020).search('hello')`

Here is the traceback: 

```
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/Users/gadirrustamli/anaconda/envs/wagtail-test/lib/python3.8/site-packages/wagtail/search/queryset.py", line 11, in search
    return search_backend.search(query, self, fields=fields,
  File "/Users/gadirrustamli/anaconda/envs/wagtail-test/lib/python3.8/site-packages/wagtail/search/backends/base.py", line 363, in search
    return self._search(
  File "/Users/gadirrustamli/anaconda/envs/wagtail-test/lib/python3.8/site-packages/wagtail/search/backends/base.py", line 358, in _search
    search_query.check()
  File "/Users/gadirrustamli/anaconda/envs/wagtail-test/lib/python3.8/site-packages/wagtail/search/backends/base.py", line 157, in check
    self._get_filters_from_where_node(self.queryset.query.where, check_only=True)
  File "/Users/gadirrustamli/anaconda/envs/wagtail-test/lib/python3.8/site-packages/wagtail/search/backends/base.py", line 108, in _get_filters_from_where_node
    child_filters = [self._get_filters_from_where_node(child) for child in where_node.children]
  File "/Users/gadirrustamli/anaconda/envs/wagtail-test/lib/python3.8/site-packages/wagtail/search/backends/base.py", line 108, in <listcomp>
    child_filters = [self._get_filters_from_where_node(child) for child in where_node.children]
  File "/Users/gadirrustamli/anaconda/envs/wagtail-test/lib/python3.8/site-packages/wagtail/search/backends/base.py", line 91, in _get_filters_from_where_node
    field_attname = where_node.lhs.target.attname
AttributeError: 'ExtractYear' object has no attribute 'target'

```


### Technical details

* Python version: 3.8
* Django version: 2.2.8
* Wagtail version: 2.8.1


Discussion:

Resolving patch:
diff --git a/wagtail/search/backends/base.py b/wagtail/search/backends/base.py
--- a/wagtail/search/backends/base.py
+++ b/wagtail/search/backends/base.py
@@ -1,5 +1,7 @@
 from warnings import warn
 
+from django.db.models.functions.datetime import Extract as ExtractDate
+from django.db.models.functions.datetime import ExtractYear
 from django.db.models.lookups import Lookup
 from django.db.models.query import QuerySet
 from django.db.models.sql.where import SubqueryConstraint, WhereNode
@@ -88,7 +90,16 @@ def _process_filter(self, field_attname, lookup, value, check_only=False):
     def _get_filters_from_where_node(self, where_node, check_only=False):
         # Check if this is a leaf node
         if isinstance(where_node, Lookup):
-            field_attname = where_node.lhs.target.attname
+            if isinstance(where_node.lhs, ExtractDate):
+                if isinstance(where_node.lhs, ExtractYear):
+                    field_attname = where_node.lhs.lhs.target.attname
+                else:
+                    raise FilterError(
+                        'Cannot apply filter on search results: "' + where_node.lhs.lookup_name
+                        + '" queries are not supported.'
+                    )
+            else:
+                field_attname = where_node.lhs.target.attname
             lookup = where_node.lookup_name
             value = where_node.rhs
 


Test patch:
diff --git a/wagtail/search/tests/elasticsearch_common_tests.py b/wagtail/search/tests/elasticsearch_common_tests.py
--- a/wagtail/search/tests/elasticsearch_common_tests.py
+++ b/wagtail/search/tests/elasticsearch_common_tests.py
@@ -176,6 +176,19 @@ def test_slice_to_next_page(self):
         results = self.backend.search(MATCH_ALL, models.Book)[110:]
         self.assertEqual(len(results), 54)
 
+    def test_search_with_date_filter(self):
+        after_1900 = models.Book.objects.filter(publication_date__year__gt=1900)
+
+        results = self.backend.search(MATCH_ALL, after_1900)
+        self.assertEqual(len(after_1900), len(results))
+
+        # Filtering by date not supported, should throw a FilterError
+        from wagtail.search.backends.base import FilterError
+
+        in_jan = models.Book.objects.filter(publication_date__month=1)
+        with self.assertRaises(FilterError):
+            self.backend.search(MATCH_ALL, in_jan)
+
     # Elasticsearch always does prefix matching on `partial_match` fields,
     # even when we don’t use `Prefix`.
     @unittest.expectedFailure
diff --git a/wagtail/search/tests/test_elasticsearch5_backend.py b/wagtail/search/tests/test_elasticsearch5_backend.py
--- a/wagtail/search/tests/test_elasticsearch5_backend.py
+++ b/wagtail/search/tests/test_elasticsearch5_backend.py
@@ -333,6 +333,17 @@ def test_phrase_query_single_field(self):
         expected_result = {'match_phrase': {'title': "Hello world"}}
         self.assertDictEqual(query_compiler.get_inner_query(), expected_result)
 
+    def test_year_filter(self):
+        # Create a query
+        query_compiler = self.query_compiler_class(models.Book.objects.filter(publication_date__year__gt=1900), "Hello")
+
+        # Check it
+        expected_result = {'bool': {'filter': [
+            {'match': {'content_type': 'searchtests.Book'}},
+            {'range': {'publication_date_filter': {'gt': 1900}}}
+        ], 'must': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
+        self.assertDictEqual(query_compiler.get_query(), expected_result)
+
 
 class TestElasticsearch5SearchResults(TestCase):
     fixtures = ['search']
diff --git a/wagtail/search/tests/test_elasticsearch6_backend.py b/wagtail/search/tests/test_elasticsearch6_backend.py
--- a/wagtail/search/tests/test_elasticsearch6_backend.py
+++ b/wagtail/search/tests/test_elasticsearch6_backend.py
@@ -333,6 +333,17 @@ def test_phrase_query_single_field(self):
         expected_result = {'match_phrase': {'title': "Hello world"}}
         self.assertDictEqual(query_compiler.get_inner_query(), expected_result)
 
+    def test_year_filter(self):
+        # Create a query
+        query_compiler = self.query_compiler_class(models.Book.objects.filter(publication_date__year=1900), "Hello")
+
+        # Check it
+        expected_result = {'bool': {'filter': [
+            {'match': {'content_type': 'searchtests.Book'}},
+            {'term': {'publication_date_filter': 1900}}
+        ], 'must': {'multi_match': {'query': 'Hello', 'fields': ['_all_text', '_edgengrams']}}}}
+        self.assertDictEqual(query_compiler.get_query(), expected_result)
+
 
 class TestElasticsearch6SearchResults(TestCase):
     fixtures = ['search']
diff --git a/wagtail/search/tests/test_elasticsearch7_backend.py b/wagtail/search/tests/test_elasticsearch7_backend.py
--- a/wagtail/search/tests/test_elasticsearch7_backend.py
+++ b/wagtail/search/tests/test_elasticsearch7_backend.py
@@ -333,6 +333,17 @@ def test_phrase_query_single_field(self):
         expected_result = {'match_phrase': {'title': "Hello world"}}
         self.assertDictEqual(query_compiler.get_inner_query(), expected_result)
 
+    def test_year_filter(self):
+        # Create a query
+        query_compiler = self.query_compiler_class(models.Book.objects.filter(publication_date__year__lt=1900), "Hello")
+
+        # Check it
+        expected_result = {'bool': {'filter': [
+            {'match': {'content_type': 'searchtests.Book'}},
+            {'range': {'publication_date_filter': {'lt': 1900}}}
+        ], 'must': {'multi_match': {'query': 'Hello', 'fields': ['_all_text', '_edgengrams']}}}}
+        self.assertDictEqual(query_compiler.get_query(), expected_result)
+
 
 class TestElasticsearch7SearchResults(TestCase):
     fixtures = ['search']