From d778c3693d152d9c4965b3a02597c6494b077e81 Mon Sep 17 00:00:00 2001 From: Nick Smith Date: Thu, 19 Jun 2014 12:17:07 +0100 Subject: [PATCH 001/154] respect DOM re-ordering of formsets even if the parent form errored --- wagtail/wagtailadmin/edit_handlers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wagtail/wagtailadmin/edit_handlers.py b/wagtail/wagtailadmin/edit_handlers.py index a15440016..d7a5726c3 100644 --- a/wagtail/wagtailadmin/edit_handlers.py +++ b/wagtail/wagtailadmin/edit_handlers.py @@ -562,6 +562,11 @@ class BaseInlinePanel(EditHandler): child_edit_handler_class(instance=subform.instance, form=subform) ) + # if this formset is valid, it may have been re-ordered; respect that + # in case the parent form errored and we need to re-render + if self.formset.can_order and self.formset.is_valid(): + self.children = sorted(self.children, key=lambda x: x.form.cleaned_data['ORDER']) + empty_form = self.formset.empty_form empty_form.fields['DELETE'].widget = forms.HiddenInput() if self.formset.can_order: From 183c73c78a4080551cc63c4623dddc3d5dc30ffd Mon Sep 17 00:00:00 2001 From: Tom Talbot Date: Tue, 24 Jun 2014 11:50:44 +0100 Subject: [PATCH 002/154] Fix test_edit_handlers breaking other tests --- wagtail/wagtailadmin/tests/test_edit_handlers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wagtail/wagtailadmin/tests/test_edit_handlers.py b/wagtail/wagtailadmin/tests/test_edit_handlers.py index c930974fe..495a3ed41 100644 --- a/wagtail/wagtailadmin/tests/test_edit_handlers.py +++ b/wagtail/wagtailadmin/tests/test_edit_handlers.py @@ -210,8 +210,10 @@ class TestBaseFieldPanel(TestCase): def setUp(self): fake_field = self.FakeField() - BaseFieldPanel.field_name = 'barbecue' - self.base_field_panel = BaseFieldPanel( + fake_base_field_panel = type('_FieldPanel', + (BaseFieldPanel,), + {'field_name': 'barbecue'}) + self.base_field_panel = fake_base_field_panel( instance=True, form={'barbecue': fake_field}) From 085324c1b6e67a6bfa7b87d76fd3df375721e764 Mon Sep 17 00:00:00 2001 From: Tom Talbot Date: Tue, 24 Jun 2014 14:30:23 +0100 Subject: [PATCH 003/154] Update snippets tests Add an Advert fixture so that Advert objects do not need to be created by the tests that use them. --- wagtail/tests/fixtures/test.json | 8 ++++++++ wagtail/wagtailsnippets/tests.py | 23 +++++++++-------------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/wagtail/tests/fixtures/test.json b/wagtail/tests/fixtures/test.json index 11b82d9f1..31b47dfdd 100644 --- a/wagtail/tests/fixtures/test.json +++ b/wagtail/tests/fixtures/test.json @@ -418,5 +418,13 @@ "page": 8, "submit_time": "2014-01-01T12:00:00.000Z" } +}, +{ + "pk": 1, + "model": "tests.advert", + "fields": { + "text": "test_advert", + "url": "http://www.example.com" + } } ] diff --git a/wagtail/wagtailsnippets/tests.py b/wagtail/wagtailsnippets/tests.py index 1b1e012de..6e18b3ba4 100644 --- a/wagtail/wagtailsnippets/tests.py +++ b/wagtail/wagtailsnippets/tests.py @@ -78,12 +78,10 @@ class TestSnippetCreateView(TestCase, WagtailTestUtils): class TestSnippetEditView(TestCase, WagtailTestUtils): - def setUp(self): - self.test_snippet = Advert() - self.test_snippet.text = 'test_advert' - self.test_snippet.url = 'http://www.example.com/' - self.test_snippet.save() + fixtures = ['wagtail/tests/fixtures/test.json'] + def setUp(self): + self.test_snippet = Advert.objects.get(id=1) self.login() def get(self, params={}): @@ -127,12 +125,10 @@ class TestSnippetEditView(TestCase, WagtailTestUtils): class TestSnippetDelete(TestCase, WagtailTestUtils): - def setUp(self): - self.test_snippet = Advert() - self.test_snippet.text = 'test_advert' - self.test_snippet.url = 'http://www.example.com/' - self.test_snippet.save() + fixtures = ['wagtail/tests/fixtures/test.json'] + def setUp(self): + self.test_snippet = Advert.objects.get(id=1) self.login() def test_delete_get(self): @@ -151,14 +147,13 @@ class TestSnippetDelete(TestCase, WagtailTestUtils): class TestSnippetChooserPanel(TestCase): + fixtures = ['wagtail/tests/fixtures/test.json'] + def setUp(self): content_type = get_content_type_from_url_params('tests', 'advert') - test_snippet = Advert() - test_snippet.text = 'test_advert' - test_snippet.url = 'http://www.example.com/' - test_snippet.save() + test_snippet = Advert.objects.get(id=1) edit_handler_class = get_snippet_edit_handler(Advert) form_class = edit_handler_class.get_form_class(Advert) From 4a922b990cc95406b9cb9e7d4f109ad8aca92ac1 Mon Sep 17 00:00:00 2001 From: Tom Talbot Date: Wed, 2 Jul 2014 16:57:14 +0100 Subject: [PATCH 004/154] Fix wagtailsnippets.tests.TestSnippetChooserPanel Fix wrong argument type passed to SnippetChooserPanel --- wagtail/wagtailsnippets/tests.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/wagtail/wagtailsnippets/tests.py b/wagtail/wagtailsnippets/tests.py index 6e18b3ba4..ab9b2427d 100644 --- a/wagtail/wagtailsnippets/tests.py +++ b/wagtail/wagtailsnippets/tests.py @@ -150,9 +150,7 @@ class TestSnippetChooserPanel(TestCase): fixtures = ['wagtail/tests/fixtures/test.json'] def setUp(self): - content_type = get_content_type_from_url_params('tests', - 'advert') - + content_type = Advert test_snippet = Advert.objects.get(id=1) edit_handler_class = get_snippet_edit_handler(Advert) @@ -170,7 +168,7 @@ class TestSnippetChooserPanel(TestCase): self.assertTrue('test_advert' in self.snippet_chooser_panel.render_as_field()) def test_render_js(self): - self.assertTrue("createSnippetChooser(fixPrefix('id_text'), 'contenttypes/contenttype');" + self.assertTrue("createSnippetChooser(fixPrefix('id_text'), 'tests/advert');" in self.snippet_chooser_panel.render_js()) From 1ce157ea432a32c737e2ad47e2b337bb699da61c Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 3 Jul 2014 09:17:55 +0100 Subject: [PATCH 005/154] Added purge_url_from cache to wagtailfontendcache.utils --- wagtail/contrib/wagtailfrontendcache/utils.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/wagtail/contrib/wagtailfrontendcache/utils.py b/wagtail/contrib/wagtailfrontendcache/utils.py index b95ed7e95..66b77c971 100644 --- a/wagtail/contrib/wagtailfrontendcache/utils.py +++ b/wagtail/contrib/wagtailfrontendcache/utils.py @@ -24,12 +24,17 @@ class CustomHTTPAdapter(HTTPAdapter): return super(CustomHTTPAdapter, self).get_connection(self.cache_url, proxies) -def purge_page_from_cache(page): +def purge_url_from_cache(url): # Get session cache_server_url = getattr(settings, 'WAGTAILFRONTENDCACHE_LOCATION', 'http://127.0.0.1:8000/') session = requests.Session() session.mount('http://', CustomHTTPAdapter(cache_server_url)) - # Purge paths from cache - for path in page.get_cached_paths(): - session.request('PURGE', page.full_url + path[1:]) + # Send purge request to cache + session.request('PURGE', url) + + +def purge_page_from_cache(page): + # Purge cached paths from cache + for path in page.specific.get_cached_paths(): + purge_url_from_cache(page.full_url + path[1:]) From e4a9756c7942411c95588501427954fdf90f5814 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 3 Jul 2014 10:14:51 +0100 Subject: [PATCH 006/154] Added little section in the docs describing purge_url_from_cache --- docs/frontend_cache_purging.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/frontend_cache_purging.rst b/docs/frontend_cache_purging.rst index a949bdc6e..e8564f6c1 100644 --- a/docs/frontend_cache_purging.rst +++ b/docs/frontend_cache_purging.rst @@ -100,3 +100,16 @@ Let's take the the above BlogIndexPage as an example. We need to register a sign @register(pre_delete, sender=BlogPage) def blog_deleted_handler(instance): blog_page_changed(instance) + + +Purging individual URLs +----------------------- + +``wagtail.contrib.wagtailfrontendcache.utils`` provides another utils function called ``purge_url_from_cache``. As the name suggests, this purges an individual URL from the cache. + +.. code-block:: python + + from wagtail.contrib.wagtailfrontendcache.utils import purge_url_from_cache + + # Purge the homepage + purge_url_from_cache(homepage.full_url) From bba9d4faf29c7a8b84e557223174f6930f0cc378 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 12:41:51 +0100 Subject: [PATCH 007/154] Added new search configuration format --- wagtail/wagtailadmin/taggable.py | 21 ++++------- wagtail/wagtailcore/models.py | 25 +++++-------- wagtail/wagtaildocs/models.py | 12 +++---- wagtail/wagtailimages/models.py | 12 +++---- wagtail/wagtailsearch/indexed.py | 60 ++++++++++++++++++++++++++++++++ wagtail/wagtailsearch/models.py | 15 +++++--- 6 files changed, 94 insertions(+), 51 deletions(-) diff --git a/wagtail/wagtailadmin/taggable.py b/wagtail/wagtailadmin/taggable.py index 39213e956..91c36ca7f 100644 --- a/wagtail/wagtailadmin/taggable.py +++ b/wagtail/wagtailadmin/taggable.py @@ -4,27 +4,20 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from wagtail.wagtailsearch import Indexed, get_search_backend +from wagtail.wagtailsearch import indexed +from wagtail.wagtailsearch.backends import get_search_backend -class TagSearchable(Indexed): +class TagSearchable(indexed.Indexed): """ Mixin to provide a 'search' method, searching on the 'title' field and tags, for models that provide those things. """ - indexed_fields = { - 'title': { - 'type': 'string', - 'analyzer': 'edgengram_analyzer', - 'boost': 10, - }, - 'get_tags': { - 'type': 'string', - 'analyzer': 'edgengram_analyzer', - 'boost': 10, - }, - } + search_fields = ( + indexed.SearchField('title', partial_match=True, boost=10), + indexed.SearchField('get_tags', partial_match=True, boost=10) + ) @property def get_tags(self): diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index 7f3070615..4964966a3 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -28,7 +28,8 @@ from treebeard.mp_tree import MP_Node from wagtail.wagtailcore.utils import camelcase_to_underscore from wagtail.wagtailcore.query import PageQuerySet -from wagtail.wagtailsearch import Indexed, get_search_backend +from wagtail.wagtailsearch import indexed +from wagtail.wagtailsearch.backends import get_search_backend class SiteManager(models.Manager): @@ -260,7 +261,7 @@ class PageBase(models.base.ModelBase): @python_2_unicode_compatible -class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)): +class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, indexed.Indexed)): title = models.CharField(max_length=255, help_text=_("The page title as you'd like it to be seen by the public")) slug = models.SlugField(help_text=_("The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/")) # TODO: enforce uniqueness on slug field per parent (will have to be done at the Django @@ -279,21 +280,11 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)): expire_at = models.DateTimeField(verbose_name=_("Expiry date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True) expired = models.BooleanField(default=False, editable=False) - indexed_fields = { - 'title': { - 'type': 'string', - 'analyzer': 'edgengram_analyzer', - 'boost': 100, - }, - 'live': { - 'type': 'boolean', - 'index': 'not_analyzed', - }, - 'path': { - 'type': 'string', - 'index': 'not_analyzed', - }, - } + search_fields = ( + indexed.SearchField('title', partial_match=True, boost=100), + indexed.FilterField('live'), + indexed.FilterField('path'), + ) def __init__(self, *args, **kwargs): super(Page, self).__init__(*args, **kwargs) diff --git a/wagtail/wagtaildocs/models.py b/wagtail/wagtaildocs/models.py index a87b9c02b..513c0ede0 100644 --- a/wagtail/wagtaildocs/models.py +++ b/wagtail/wagtaildocs/models.py @@ -12,6 +12,7 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import python_2_unicode_compatible from wagtail.wagtailadmin.taggable import TagSearchable +from wagtail.wagtailsearch import indexed @python_2_unicode_compatible @@ -23,14 +24,9 @@ class Document(models.Model, TagSearchable): tags = TaggableManager(help_text=None, blank=True, verbose_name=_('Tags')) - indexed_fields = { - 'uploaded_by_user_id': { - 'type': 'integer', - 'store': 'yes', - 'indexed': 'no', - 'boost': 0, - }, - } + search_fields = TagSearchable.search_fields + ( + indexed.FilterField('uploaded_by_user'), + ) def __str__(self): return self.title diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index db73b048a..c561cdcc5 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -20,6 +20,7 @@ from unidecode import unidecode from wagtail.wagtailadmin.taggable import TagSearchable from wagtail.wagtailimages.backends import get_image_backend +from wagtail.wagtailsearch import indexed from .utils import validate_image_format @@ -48,14 +49,9 @@ class AbstractImage(models.Model, TagSearchable): tags = TaggableManager(help_text=None, blank=True, verbose_name=_('Tags')) - indexed_fields = { - 'uploaded_by_user_id': { - 'type': 'integer', - 'store': 'yes', - 'indexed': 'no', - 'boost': 0, - }, - } + search_fields = TagSearchable.search_fields + ( + indexed.FilterField('uploaded_by_user'), + ) def __str__(self): return self.title diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index b53481727..e2817905d 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -35,6 +35,11 @@ class Indexed(object): @classmethod def indexed_get_indexed_fields(cls): + # New way + if hasattr(cls, 'search_fields'): + return dict((field.get_attname(cls), field.to_dict(cls)) for field in cls.search_fields) + + # Old way # Get indexed fields for this class as dictionary indexed_fields = cls.indexed_fields if isinstance(indexed_fields, dict): @@ -83,3 +88,58 @@ class Indexed(object): return doc indexed_fields = () + + +class BaseField(object): + def __init__(self, field_name, **kwargs): + self.field_name = field_name + self.kwargs = kwargs + + def get_field(self, cls): + return cls._meta.get_field_by_name(self.field_name)[0] + + def get_attname(self, cls): + try: + field = self.get_field(cls) + return field.attname + except models.fields.FieldDoesNotExist: + return self.field_name + + def to_dict(self, cls): + dic = { + 'type': 'string' + } + + if 'es_extra' in self.kwargs: + for key, value in self.kwargs['es_extra'].items(): + dic[key] = value + + return dic + + +class SearchField(BaseField): + def __init__(self, field_name, boost=None, partial_match=False, **kwargs): + super(SearchField, self).__init__(field_name, **kwargs) + self.boost = boost + self.partial_match = partial_match + + def to_dict(self, cls): + dic = super(SearchField, self).to_dict(cls) + + if self.boost and 'boost' not in dic: + dic['boost'] = self.boost + + if self.partial_match and 'analyzer' not in dic: + dic['analyzer'] = 'edgengram_analyzer' + + return dic + + +class FilterField(BaseField): + def to_dict(self, cls): + dic = super(FilterField, self).to_dict(cls) + + if 'index' not in dic: + dic['index'] = 'not_analyzed' + + return dic diff --git a/wagtail/wagtailsearch/models.py b/wagtail/wagtailsearch/models.py index abb479295..509368463 100644 --- a/wagtail/wagtailsearch/models.py +++ b/wagtail/wagtailsearch/models.py @@ -4,7 +4,7 @@ from django.db import models from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible -from wagtail.wagtailsearch.indexed import Indexed +from wagtail.wagtailsearch import indexed from wagtail.wagtailsearch.utils import normalise_query_string, MAX_QUERY_STRING_LENGTH @@ -82,12 +82,17 @@ class EditorsPick(models.Model): # Used for tests -class SearchTest(models.Model, Indexed): +class SearchTest(models.Model, indexed.Indexed): title = models.CharField(max_length=255) content = models.TextField() live = models.BooleanField(default=False) - indexed_fields = ("title", "content", "callable_indexed_field", "live") + search_fields = ( + indexed.SearchField('title'), + indexed.SearchField('content'), + indexed.SearchField('callable_indexed_field'), + indexed.SearchField('live'), + ) def callable_indexed_field(self): return "Callable" @@ -96,4 +101,6 @@ class SearchTest(models.Model, Indexed): class SearchTestChild(SearchTest): extra_content = models.TextField() - indexed_fields = "extra_content" + search_fields = SearchTest.search_fields + ( + indexed.SearchField('extra_content'), + ) From 5c64172d02fc2fded5f4ed990f62fa836c5302c3 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 13:44:54 +0100 Subject: [PATCH 008/154] Created ElasticSearchMapping class --- .../wagtailsearch/backends/elasticsearch.py | 92 ++++++++++++------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 91536d744..82e76d5fc 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -12,6 +12,40 @@ from wagtail.wagtailsearch.indexed import Indexed from wagtail.wagtailsearch.utils import normalise_query_string +class ElasticSearchMapping(object): + def __init__(self, model): + self.model = model + + def get_document_type(self): + return self.model.indexed_get_content_type() + + def get_mapping(self): + # Get type name + content_type = self.get_document_type() + + # Get indexed fields + indexed_fields = self.model.indexed_get_indexed_fields() + + # Make field list + fields = { + 'pk': dict(type='string', index='not_analyzed', store='yes'), + 'content_type': dict(type='string'), + } + fields.update(indexed_fields) + + return { + content_type: { + 'properties': fields, + } + } + + def get_document_id(self, obj): + return obj.indexed_build_document()['id'] + + def get_document(self, obj): + return obj.indexed_build_document() + + class ElasticSearchQuery(object): def __init__(self, model, query_string, fields=None, filters={}): self.model = model @@ -330,25 +364,11 @@ class ElasticSearch(BaseSearch): self.es.indices.create(self.es_index, INDEX_SETTINGS) def add_type(self, model): - # Get type name - content_type = model.indexed_get_content_type() - - # Get indexed fields - indexed_fields = model.indexed_get_indexed_fields() - - # Make field list - fields = { - "pk": dict(type="string", index="not_analyzed", store="yes"), - "content_type": dict(type="string"), - } - fields.update(indexed_fields) + # Get mapping + mapping = ElasticSearchMapping(model) # Put mapping - self.es.indices.put_mapping(index=self.es_index, doc_type=content_type, body={ - content_type: { - "properties": fields, - } - }) + self.es.indices.put_mapping(index=self.es_index, doc_type=mapping.get_document_type(), body=mapping.get_mapping()) def refresh_index(self): self.es.indices.refresh(self.es_index) @@ -358,11 +378,11 @@ class ElasticSearch(BaseSearch): if not self.object_can_be_indexed(obj): return - # Build document - doc = obj.indexed_build_document() + # Get mapping + mapping = ElasticSearchMapping(obj.__class__) - # Add to index - self.es.index(self.es_index, obj.indexed_get_content_type(), doc, id=doc["id"]) + # Add document to index + self.es.index(self.es_index, mapping.get_document_type(), mapping.get_document(obj), id=mapping.get_document_id(obj)) def add_bulk(self, obj_list): # Group all objects by their type @@ -372,27 +392,30 @@ class ElasticSearch(BaseSearch): if not self.object_can_be_indexed(obj): continue - # Get object type - obj_type = obj.indexed_get_content_type() + # Get mapping + mapping = ElasticSearchMapping(obj.__class__) + + # Get document type + doc_type = mapping.get_document_type() # If type is currently not in set, add it - if obj_type not in type_set: - type_set[obj_type] = [] + if doc_type not in type_set: + type_set[doc_type] = [] - # Add object to set - type_set[obj_type].append(obj.indexed_build_document()) + # Add document to set + type_set[doc_type].append((mapping.get_document_id(obj), mapping.get_document(obj))) # Loop through each type and bulk add them - for type_name, type_objects in type_set.items(): + for type_name, type_documents in type_set.items(): # Get list of actions actions = [] - for obj in type_objects: + for doc_id, doc in type_documents: action = { '_index': self.es_index, '_type': type_name, - '_id': obj['id'], + '_id': doc_id, } - action.update(obj) + action.update(doc) actions.append(action) bulk(self.es, actions) @@ -402,12 +425,15 @@ class ElasticSearch(BaseSearch): if not isinstance(obj, Indexed) or not isinstance(obj, models.Model): return + # Get mapping + mapping = ElasticSearchMapping(obj.__class__) + # Delete document try: self.es.delete( self.es_index, - obj.indexed_get_content_type(), - obj.indexed_get_document_id(), + mapping.get_document_type(), + mapping.get_document_id(obj), ) except NotFoundError: pass # Document doesn't exist, ignore this exception From fefa8c79ef7660d789f109a444ebe2b9fc8b43f8 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 13:51:35 +0100 Subject: [PATCH 009/154] Added repr to ElasticSearchMapping --- wagtail/wagtailsearch/backends/elasticsearch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 82e76d5fc..9ad1f288a 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -45,6 +45,9 @@ class ElasticSearchMapping(object): def get_document(self, obj): return obj.indexed_build_document() + def __repr__(self): + return '' % (self.model.__name__, ) + class ElasticSearchQuery(object): def __init__(self, model, query_string, fields=None, filters={}): From da9b7c2408474583d66bf1be274e9a0831f36a40 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 13:55:09 +0100 Subject: [PATCH 010/154] Moved document building methods from indexed into ElasticSearchMapping --- .../wagtailsearch/backends/elasticsearch.py | 19 ++++++++++++++-- wagtail/wagtailsearch/indexed.py | 22 ------------------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 9ad1f288a..bbdbf0ecd 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -40,10 +40,25 @@ class ElasticSearchMapping(object): } def get_document_id(self, obj): - return obj.indexed_build_document()['id'] + return obj.indexed_get_toplevel_content_type() + ':' + str(obj.pk) def get_document(self, obj): - return obj.indexed_build_document() + # Get content type, indexed fields and id + content_type = obj.indexed_get_content_type() + indexed_fields = obj.indexed_get_indexed_fields() + + # Build document + doc = dict(pk=str(obj.pk), content_type=content_type) + for field in indexed_fields.keys(): + if hasattr(obj, field): + doc[field] = getattr(obj, field) + + # Check if this field is callable + if hasattr(doc[field], "__call__"): + # Call it + doc[field] = doc[field]() + + return doc def __repr__(self): return '' % (self.model.__name__, ) diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index e2817905d..929bd0dbe 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -65,28 +65,6 @@ class Indexed(object): indexed_fields = parent_indexed_fields return indexed_fields - def indexed_get_document_id(self): - return self.indexed_get_toplevel_content_type() + ":" + str(self.pk) - - def indexed_build_document(self): - # Get content type, indexed fields and id - content_type = self.indexed_get_content_type() - indexed_fields = self.indexed_get_indexed_fields() - doc_id = self.indexed_get_document_id() - - # Build document - doc = dict(pk=str(self.pk), content_type=content_type, id=doc_id) - for field in indexed_fields.keys(): - if hasattr(self, field): - doc[field] = getattr(self, field) - - # Check if this field is callable - if hasattr(doc[field], "__call__"): - # Call it - doc[field] = doc[field]() - - return doc - indexed_fields = () From e81a9f6c6b3aed2a78ea16890dd34930b1f85ab9 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 14:56:56 +0100 Subject: [PATCH 011/154] Added repr to indexed.BaseField --- wagtail/wagtailsearch/indexed.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index 929bd0dbe..334bb30da 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -94,6 +94,9 @@ class BaseField(object): return dic + def __repr__(self): + return "<%s: %s>" % (self.__class__.__name__, self.field_name) + class SearchField(BaseField): def __init__(self, field_name, boost=None, partial_match=False, **kwargs): From 3c5a065a3601ff5b23df8ac9081dc5c88a532234 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 15:00:57 +0100 Subject: [PATCH 012/154] SearchFields configuration is now used in the backends Previously, we just converted them to make them look like indexed_fields. We now convert indexed_fields into SearchFields objects and pass them to the backend. --- wagtail/wagtailsearch/backends/db.py | 2 +- .../wagtailsearch/backends/elasticsearch.py | 20 ++----- wagtail/wagtailsearch/indexed.py | 56 +++++++++++++++++-- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/wagtail/wagtailsearch/backends/db.py b/wagtail/wagtailsearch/backends/db.py index 214c71cb3..a7dca12e9 100644 --- a/wagtail/wagtailsearch/backends/db.py +++ b/wagtail/wagtailsearch/backends/db.py @@ -38,7 +38,7 @@ class DBSearch(BaseSearch): # Get fields if fields is None: - fields = model.indexed_get_indexed_fields().keys() + fields = [field.field_name for field in model.get_searchable_search_fields()] # Start will all objects query = model.objects.all() diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index bbdbf0ecd..28127fe83 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -20,21 +20,17 @@ class ElasticSearchMapping(object): return self.model.indexed_get_content_type() def get_mapping(self): - # Get type name - content_type = self.get_document_type() - - # Get indexed fields - indexed_fields = self.model.indexed_get_indexed_fields() - # Make field list fields = { 'pk': dict(type='string', index='not_analyzed', store='yes'), 'content_type': dict(type='string'), } - fields.update(indexed_fields) + + for field in self.model.get_search_fields(): + fields[field.get_attname(self.model)] = field.to_dict(self.model) return { - content_type: { + self.get_document_type(): { 'properties': fields, } } @@ -43,13 +39,9 @@ class ElasticSearchMapping(object): return obj.indexed_get_toplevel_content_type() + ':' + str(obj.pk) def get_document(self, obj): - # Get content type, indexed fields and id - content_type = obj.indexed_get_content_type() - indexed_fields = obj.indexed_get_indexed_fields() - # Build document - doc = dict(pk=str(obj.pk), content_type=content_type) - for field in indexed_fields.keys(): + doc = dict(pk=str(obj.pk), content_type=self.model.indexed_get_content_type()) + for field in [field.get_attname(self.model) for field in self.model.get_search_fields()]: if hasattr(obj, field): doc[field] = getattr(obj, field) diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index 334bb30da..b4e67f4c1 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -1,3 +1,5 @@ +import warnings + from six import string_types from django.db import models @@ -35,11 +37,6 @@ class Indexed(object): @classmethod def indexed_get_indexed_fields(cls): - # New way - if hasattr(cls, 'search_fields'): - return dict((field.get_attname(cls), field.to_dict(cls)) for field in cls.search_fields) - - # Old way # Get indexed fields for this class as dictionary indexed_fields = cls.indexed_fields if isinstance(indexed_fields, dict): @@ -65,10 +62,57 @@ class Indexed(object): indexed_fields = parent_indexed_fields return indexed_fields + @classmethod + def get_search_fields(cls): + search_fields = [] + + if hasattr(cls, 'search_fields'): + search_fields.extend(cls.search_fields) + + # Backwards compatibility with old indexed_fields setting + + # Get indexed fields + indexed_fields = cls.indexed_get_indexed_fields() + + # Display deprecation warning if indexed_fields has been used + if indexed_fields: + warnings.warn("'indexed_fields' setting is now deprecated." + "Use 'search_fields' instead.", DeprecationWarning) + + # Convert them into search fields + for field_name, _config in indexed_fields.items(): + # Copy the config to prevent is trashing anything accidentally + config = _config.copy() + + # Check if this is a filter field + if config.get('index', None) == 'not_analyzed': + config.pop('index') + search_fields.append(FilterField(field_name, es_extra=config)) + continue + + # Must be a search field, check for boosting and partial matching + boost = config.pop('boost', None) + + partial_match = False + if config.get('analyzer', None) == 'edgengram_analyzer': + partial_match = True + config.pop('analyzer') + + # Add the field + search_fields.append(SearchField(field_name, boost=boost, partial_match=partial_match, es_extra=config)) + + return search_fields + + @classmethod + def get_searchable_search_fields(cls): + return filter(lambda field: field.searchable, cls.get_search_fields()) + indexed_fields = () class BaseField(object): + searchable = False + def __init__(self, field_name, **kwargs): self.field_name = field_name self.kwargs = kwargs @@ -99,6 +143,8 @@ class BaseField(object): class SearchField(BaseField): + searchable = True + def __init__(self, field_name, boost=None, partial_match=False, **kwargs): super(SearchField, self).__init__(field_name, **kwargs) self.boost = boost From 8bb3703c6eea10e4e02abab134d03131ec68e1d8 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 15:09:31 +0100 Subject: [PATCH 013/154] Created get_field_mapping method on ElasticSearchMapping class --- wagtail/wagtailsearch/backends/elasticsearch.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 28127fe83..ac8ee068b 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -19,6 +19,9 @@ class ElasticSearchMapping(object): def get_document_type(self): return self.model.indexed_get_content_type() + def get_field_mapping(self, field): + return field.get_attname(self.model), field.to_dict(self.model) + def get_mapping(self): # Make field list fields = { @@ -26,8 +29,9 @@ class ElasticSearchMapping(object): 'content_type': dict(type='string'), } - for field in self.model.get_search_fields(): - fields[field.get_attname(self.model)] = field.to_dict(self.model) + fields.update(dict( + self.get_field_mapping(field) for field in self.model.get_search_fields() + )) return { self.get_document_type(): { From 0aca9ece6576640cfd8bac0b508f3a2ffde3ce8b Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 15:29:09 +0100 Subject: [PATCH 014/154] Moved field mapping generation code into ElasticSearchMapping class --- .../wagtailsearch/backends/elasticsearch.py | 19 +++++++++-- wagtail/wagtailsearch/indexed.py | 32 +++---------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index ac8ee068b..88542f624 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -8,7 +8,7 @@ from elasticsearch import Elasticsearch, NotFoundError, RequestError from elasticsearch.helpers import bulk from wagtail.wagtailsearch.backends.base import BaseSearch -from wagtail.wagtailsearch.indexed import Indexed +from wagtail.wagtailsearch.indexed import Indexed, SearchField, FilterField from wagtail.wagtailsearch.utils import normalise_query_string @@ -20,7 +20,22 @@ class ElasticSearchMapping(object): return self.model.indexed_get_content_type() def get_field_mapping(self, field): - return field.get_attname(self.model), field.to_dict(self.model) + mapping = {'type': 'string'} + + if isinstance(field, SearchField): + if field.boost: + mapping['boost'] = field.boost + + if field.partial_match: + mapping['analyzer'] = 'edgengram_analyzer' + elif isinstance(field, FilterField): + mapping['index'] = 'not_analyzed' + + if 'es_extra' in field.kwargs: + for key, value in field.kwargs['es_extra'].items(): + mapping[key] = value + + return field.get_index_name(self.model), mapping def get_mapping(self): # Make field list diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index b4e67f4c1..347e41c0c 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -112,6 +112,7 @@ class Indexed(object): class BaseField(object): searchable = False + suffix = '' def __init__(self, field_name, **kwargs): self.field_name = field_name @@ -127,16 +128,8 @@ class BaseField(object): except models.fields.FieldDoesNotExist: return self.field_name - def to_dict(self, cls): - dic = { - 'type': 'string' - } - - if 'es_extra' in self.kwargs: - for key, value in self.kwargs['es_extra'].items(): - dic[key] = value - - return dic + def get_index_name(self, cls): + return self.get_attname(cls) + self.suffix def __repr__(self): return "<%s: %s>" % (self.__class__.__name__, self.field_name) @@ -150,23 +143,6 @@ class SearchField(BaseField): self.boost = boost self.partial_match = partial_match - def to_dict(self, cls): - dic = super(SearchField, self).to_dict(cls) - - if self.boost and 'boost' not in dic: - dic['boost'] = self.boost - - if self.partial_match and 'analyzer' not in dic: - dic['analyzer'] = 'edgengram_analyzer' - - return dic - class FilterField(BaseField): - def to_dict(self, cls): - dic = super(FilterField, self).to_dict(cls) - - if 'index' not in dic: - dic['index'] = 'not_analyzed' - - return dic + suffix = '_filter' From 5e76a54b2b4046b4a6a177c04c3ad8fc3bc345fe Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 15:36:10 +0100 Subject: [PATCH 015/154] Remove any duplicate search fields of the same type --- wagtail/wagtailsearch/indexed.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index 347e41c0c..aba3003f7 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -101,6 +101,13 @@ class Indexed(object): # Add the field search_fields.append(SearchField(field_name, boost=boost, partial_match=partial_match, es_extra=config)) + # Remove any duplicate entries into search fields + # We need to take into account that fields can be indexed as both a SearchField and as a FilterField + search_fields_dict = {} + for field in search_fields: + search_fields_dict[(field.field_name, type(field))] = field + search_fields = search_fields_dict.values() + return search_fields @classmethod From 6d21727b034a1f0147c1be987aab27e4728a5199 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 15:48:51 +0100 Subject: [PATCH 016/154] Cleaned up database backend search method --- wagtail/wagtailsearch/backends/db.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/wagtail/wagtailsearch/backends/db.py b/wagtail/wagtailsearch/backends/db.py index a7dca12e9..5ed8e07df 100644 --- a/wagtail/wagtailsearch/backends/db.py +++ b/wagtail/wagtailsearch/backends/db.py @@ -49,7 +49,7 @@ class DBSearch(BaseSearch): # Filter by terms for term in terms: - term_query = None + term_query = models.Q() for field_name in fields: # Check if the field exists (this will filter out indexed callables) try: @@ -58,11 +58,8 @@ class DBSearch(BaseSearch): continue # Filter on this field - field_filter = {'%s__icontains' % field_name: term} - if term_query is None: - term_query = models.Q(**field_filter) - else: - term_query |= models.Q(**field_filter) + term_query |= models.Q(**{'%s__icontains' % field_name: term}) + query = query.filter(term_query) # Distinct From 67ed563dd74602a1cfb79b2d1e274c3c8aa8cf2d Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 15:52:06 +0100 Subject: [PATCH 017/154] Made quotes more consistant --- .../wagtailsearch/backends/elasticsearch.py | 62 +++++++++---------- wagtail/wagtailsearch/indexed.py | 4 +- .../management/commands/update_index.py | 6 +- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 88542f624..4890b5e52 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -65,7 +65,7 @@ class ElasticSearchMapping(object): doc[field] = getattr(obj, field) # Check if this field is callable - if hasattr(doc[field], "__call__"): + if hasattr(doc[field], '__call__'): # Call it doc[field] = doc[field]() @@ -346,43 +346,43 @@ class ElasticSearch(BaseSearch): # Settings INDEX_SETTINGS = { - "settings": { - "analysis": { - "analyzer": { - "ngram_analyzer": { - "type": "custom", - "tokenizer": "lowercase", - "filter": ["ngram"] + 'settings': { + 'analysis': { + 'analyzer': { + 'ngram_analyzer': { + 'type': 'custom', + 'tokenizer': 'lowercase', + 'filter': ['ngram'] }, - "edgengram_analyzer": { - "type": "custom", - "tokenizer": "lowercase", - "filter": ["edgengram"] + 'edgengram_analyzer': { + 'type': 'custom', + 'tokenizer': 'lowercase', + 'filter': ['edgengram'] } }, - "tokenizer": { - "ngram_tokenizer": { - "type": "nGram", - "min_gram": 3, - "max_gram": 15, + 'tokenizer': { + 'ngram_tokenizer': { + 'type': 'nGram', + 'min_gram': 3, + 'max_gram': 15, }, - "edgengram_tokenizer": { - "type": "edgeNGram", - "min_gram": 2, - "max_gram": 15, - "side": "front" + 'edgengram_tokenizer': { + 'type': 'edgeNGram', + 'min_gram': 2, + 'max_gram': 15, + 'side': 'front' } }, - "filter": { - "ngram": { - "type": "nGram", - "min_gram": 3, - "max_gram": 15 + 'filter': { + 'ngram': { + 'type': 'nGram', + 'min_gram': 3, + 'max_gram': 15 }, - "edgengram": { - "type": "edgeNGram", - "min_gram": 1, - "max_gram": 15 + 'edgengram': { + 'type': 'edgeNGram', + 'min_gram': 1, + 'max_gram': 15 } } } diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index aba3003f7..b4626914f 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -49,7 +49,7 @@ class Indexed(object): if isinstance(indexed_fields, string_types): indexed_fields = [indexed_fields] if isinstance(indexed_fields, list): - indexed_fields = dict((field, dict(type="string")) for field in indexed_fields) + indexed_fields = dict((field, dict(type='string')) for field in indexed_fields) if not isinstance(indexed_fields, dict): raise ValueError() @@ -139,7 +139,7 @@ class BaseField(object): return self.get_attname(cls) + self.suffix def __repr__(self): - return "<%s: %s>" % (self.__class__.__name__, self.field_name) + return '<%s: %s>' % (self.__class__.__name__, self.field_name) class SearchField(BaseField): diff --git a/wagtail/wagtailsearch/management/commands/update_index.py b/wagtail/wagtailsearch/management/commands/update_index.py index 8f1605b24..ee85da8ff 100644 --- a/wagtail/wagtailsearch/management/commands/update_index.py +++ b/wagtail/wagtailsearch/management/commands/update_index.py @@ -25,13 +25,13 @@ class Command(BaseCommand): # Loop through objects for obj in model.objects.all(): - # Check if this object has an "object_indexed" function - if hasattr(obj, "object_indexed"): + # Check if this object has an 'object_indexed' function + if hasattr(obj, 'object_indexed'): if obj.object_indexed() is False: continue # Get key for this object - key = toplevel_content_type + ":" + str(obj.pk) + key = toplevel_content_type + ':' + str(obj.pk) # Check if this key already exists if key in object_set: From f2d1c803783a4e69e84d46b5f87dd58b41cfc770 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 6 Apr 2014 14:21:40 +0100 Subject: [PATCH 018/154] Don't run tests with wagtailsearch signal handlers enabled --- wagtail/tests/urls.py | 4 ---- wagtail/wagtailsearch/tests/test_backends.py | 10 +++++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/wagtail/tests/urls.py b/wagtail/tests/urls.py index 83e12adfb..385d0e6cf 100644 --- a/wagtail/tests/urls.py +++ b/wagtail/tests/urls.py @@ -6,10 +6,6 @@ from wagtail.wagtaildocs import urls as wagtaildocs_urls from wagtail.wagtailsearch.urls import frontend as wagtailsearch_frontend_urls from wagtail.contrib.wagtailsitemaps.views import sitemap -# Signal handlers -from wagtail.wagtailsearch import register_signal_handlers as wagtailsearch_register_signal_handlers -wagtailsearch_register_signal_handlers() - urlpatterns = patterns('', url(r'^admin/', include(wagtailadmin_urls)), diff --git a/wagtail/wagtailsearch/tests/test_backends.py b/wagtail/wagtailsearch/tests/test_backends.py index 8fab1d152..86a67f21a 100644 --- a/wagtail/wagtailsearch/tests/test_backends.py +++ b/wagtail/wagtailsearch/tests/test_backends.py @@ -11,11 +11,6 @@ from wagtail.wagtailsearch.backends.db import DBSearch from wagtail.wagtailsearch.backends import InvalidSearchBackendError -# Register wagtailsearch signal handlers -from wagtail.wagtailsearch import register_signal_handlers -register_signal_handlers() - - class BackendTests(object): # To test a specific backend, subclass BackendTests and define self.backend_path. @@ -41,21 +36,25 @@ class BackendTests(object): testa = models.SearchTest() testa.title = "Hello World" testa.save() + self.backend.add(testa) self.testa = testa testb = models.SearchTest() testb.title = "Hello" testb.live = True testb.save() + self.backend.add(testb) testc = models.SearchTestChild() testc.title = "Hello" testc.live = True testc.save() + self.backend.add(testc) testd = models.SearchTestChild() testd.title = "World" testd.save() + self.backend.add(testd) # Refresh the index self.backend.refresh_index() @@ -130,6 +129,7 @@ class BackendTests(object): def test_delete(self): # Delete one of the objects + self.backend.delete(self.testa) self.testa.delete() # Refresh index From 6847109bb994580e67ca33ba93f1c382ab81ec6e Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Tue, 29 Apr 2014 11:54:02 +0100 Subject: [PATCH 019/154] Removed 'object_indexed' check from update_index command Conflicts: wagtail/wagtailsearch/management/commands/update_index.py --- wagtail/wagtailsearch/management/commands/update_index.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/wagtail/wagtailsearch/management/commands/update_index.py b/wagtail/wagtailsearch/management/commands/update_index.py index ee85da8ff..0b4cb695f 100644 --- a/wagtail/wagtailsearch/management/commands/update_index.py +++ b/wagtail/wagtailsearch/management/commands/update_index.py @@ -25,11 +25,6 @@ class Command(BaseCommand): # Loop through objects for obj in model.objects.all(): - # Check if this object has an 'object_indexed' function - if hasattr(obj, 'object_indexed'): - if obj.object_indexed() is False: - continue - # Get key for this object key = toplevel_content_type + ':' + str(obj.pk) From b1fb9dc2e249024961323d6f34ce4ee970a698fe Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 15:59:39 +0100 Subject: [PATCH 020/154] Fixed a few more double quotes --- wagtail/wagtailsearch/indexed.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index b4626914f..4b636497b 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -15,13 +15,13 @@ class Indexed(object): @classmethod def indexed_get_content_type(cls): # Work out content type - content_type = (cls._meta.app_label + "_" + cls.__name__).lower() + content_type = (cls._meta.app_label + '_' + cls.__name__).lower() # Get parent content type parent = cls.indexed_get_parent() if parent: parent_content_type = parent.indexed_get_content_type() - return parent_content_type + "_" + content_type + return parent_content_type + '_' + content_type else: return content_type @@ -33,7 +33,7 @@ class Indexed(object): return parent.indexed_get_content_type() else: # At toplevel, return this content type - return (cls._meta.app_label + "_" + cls.__name__).lower() + return (cls._meta.app_label + '_' + cls.__name__).lower() @classmethod def indexed_get_indexed_fields(cls): From 4a70a4251b7ec5140351f7d2f255d3ccfc81894b Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 6 Apr 2014 14:41:40 +0100 Subject: [PATCH 021/154] Give some feedback from the add_bulk command --- wagtail/wagtailsearch/backends/db.py | 4 ++-- wagtail/wagtailsearch/backends/elasticsearch.py | 1 + wagtail/wagtailsearch/management/commands/update_index.py | 6 ++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/wagtail/wagtailsearch/backends/db.py b/wagtail/wagtailsearch/backends/db.py index 5ed8e07df..4c05b19df 100644 --- a/wagtail/wagtailsearch/backends/db.py +++ b/wagtail/wagtailsearch/backends/db.py @@ -22,7 +22,7 @@ class DBSearch(BaseSearch): pass # Not needed def add_bulk(self, obj_list): - pass # Not needed + return [] # Not needed def delete(self, obj): pass # Not needed @@ -70,4 +70,4 @@ class DBSearch(BaseSearch): for prefetch in prefetch_related: query = query.prefetch_related(prefetch) - return query \ No newline at end of file + return query diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 4890b5e52..dbe755ff1 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -447,6 +447,7 @@ class ElasticSearch(BaseSearch): action.update(doc) actions.append(action) + yield type_name, len(type_documents) bulk(self.es, actions) def delete(self, obj): diff --git a/wagtail/wagtailsearch/management/commands/update_index.py b/wagtail/wagtailsearch/management/commands/update_index.py index 0b4cb695f..2c1593635 100644 --- a/wagtail/wagtailsearch/management/commands/update_index.py +++ b/wagtail/wagtailsearch/management/commands/update_index.py @@ -57,10 +57,8 @@ class Command(BaseCommand): # Add objects to index self.stdout.write("Adding objects") - results = s.add_bulk(object_set.values()) - if results: - for result in results: - self.stdout.write(result[0] + ' ' + str(result[1])) + for result in s.add_bulk(object_set.values()): + self.stdout.write(result[0] + ' ' + str(result[1])) # Refresh index self.stdout.write("Refreshing index") From 673da4ab021eb7f2fa14d9e194d12689373ba9c5 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 17:19:32 +0100 Subject: [PATCH 022/154] Set index:'not_analysed' setting on content_type field --- wagtail/wagtailsearch/backends/elasticsearch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index dbe755ff1..8fb706bba 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -41,7 +41,7 @@ class ElasticSearchMapping(object): # Make field list fields = { 'pk': dict(type='string', index='not_analyzed', store='yes'), - 'content_type': dict(type='string'), + 'content_type': dict(type='string', index='not_analyzed'), } fields.update(dict( From 584f5a7049a587f1da398ad421f9afd40a13fde8 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 16:22:35 +0100 Subject: [PATCH 023/154] Index fields with correct type in ElasticSearch Previously, everything was converted to a string before indexing in ElasticSearch. This caused issues where certian filters may not work as expected (such as a greater than filter on an integer field) This commit changes this by adding type conversion into the ElasticSearch backend. --- .../wagtailsearch/backends/elasticsearch.py | 38 ++++++++++++++----- wagtail/wagtailsearch/indexed.py | 20 ++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 8fb706bba..7ffebd045 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -13,6 +13,32 @@ from wagtail.wagtailsearch.utils import normalise_query_string class ElasticSearchMapping(object): + TYPE_MAP = { + 'AutoField': 'integer', + 'BinaryField': 'binary', + 'BooleanField': 'boolean', + 'CharField': 'string', + 'CommaSeparatedIntegerField': 'string', + 'DateField': 'date', + 'DateTimeField': 'date', + 'DecimalField': 'double', + 'FileField': 'string', + 'FilePathField': 'string', + 'FloatField': 'double', + 'IntegerField': 'integer', + 'BigIntegerField': 'long', + 'IPAddressField': 'string', + 'GenericIPAddressField': 'string', + 'NullBooleanField': 'boolean', + 'OneToOneField': 'integer', + 'PositiveIntegerField': 'integer', + 'PositiveSmallIntegerField': 'integer', + 'SlugField': 'string', + 'SmallIntegerField': 'integer', + 'TextField': 'string', + 'TimeField': 'date', + } + def __init__(self, model): self.model = model @@ -20,7 +46,7 @@ class ElasticSearchMapping(object): return self.model.indexed_get_content_type() def get_field_mapping(self, field): - mapping = {'type': 'string'} + mapping = {'type': self.TYPE_MAP.get(field.get_type(self.model), 'string')} if isinstance(field, SearchField): if field.boost: @@ -60,14 +86,8 @@ class ElasticSearchMapping(object): def get_document(self, obj): # Build document doc = dict(pk=str(obj.pk), content_type=self.model.indexed_get_content_type()) - for field in [field.get_attname(self.model) for field in self.model.get_search_fields()]: - if hasattr(obj, field): - doc[field] = getattr(obj, field) - - # Check if this field is callable - if hasattr(doc[field], '__call__'): - # Call it - doc[field] = doc[field]() + for field in self.model.get_search_fields(): + doc[field.get_index_name(self.model)] = field.get_value(obj) return doc diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index 4b636497b..b290f440b 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -138,6 +138,26 @@ class BaseField(object): def get_index_name(self, cls): return self.get_attname(cls) + self.suffix + def get_type(self, cls): + if 'type' in self.kwargs: + return self.kwargs['type'] + + try: + field = self.get_field(cls) + return field.get_internal_type() + except models.fields.FieldDoesNotExist: + return 'CharField' + + def get_value(self, obj): + try: + field = self.get_field(obj.__class__) + return field._get_val_from_obj(obj) + except models.fields.FieldDoesNotExist: + value = getattr(obj, self.field_name, None) + if hasattr(value, '__call__'): + value = value() + return value + def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, self.field_name) From de3f852e4320e6b1211b2660fa3b60a08377210e Mon Sep 17 00:00:00 2001 From: Tom Talbot Date: Thu, 3 Jul 2014 11:41:23 +0100 Subject: [PATCH 024/154] Removed EditHandler tests. Added more FieldPanel tests --- .../wagtailadmin/tests/test_edit_handlers.py | 133 ++++++------------ 1 file changed, 44 insertions(+), 89 deletions(-) diff --git a/wagtail/wagtailadmin/tests/test_edit_handlers.py b/wagtail/wagtailadmin/tests/test_edit_handlers.py index 53afbd349..fa9f3f650 100644 --- a/wagtail/wagtailadmin/tests/test_edit_handlers.py +++ b/wagtail/wagtailadmin/tests/test_edit_handlers.py @@ -63,70 +63,6 @@ class TestExtractPanelDefinitionsFromModelClass(TestCase): self.assertTrue(issubclass(panel, BaseFieldPanel)) -class TestEditHandler(TestCase): - class FakeForm(dict): - def __init__(self, *args, **kwargs): - self.fields = self.fields_iterator() - - def fields_iterator(self): - for i in self: - yield i - - def setUp(self): - self.edit_handler = EditHandler(form=True, instance=True) - self.edit_handler.render = lambda: "foo" - - def test_widget_overrides(self): - result = EditHandler.widget_overrides() - self.assertEqual(result, {}) - - def test_required_formsets(self): - result = EditHandler.required_formsets() - self.assertEqual(result, []) - - def test_get_form_class(self): - result = EditHandler.get_form_class(Page) - self.assertTrue(issubclass(result, WagtailAdminModelForm)) - - def test_edit_handler_init_no_instance(self): - self.assertRaises(ValueError, EditHandler, form=True) - - def test_edit_handler_init_no_form(self): - self.assertRaises(ValueError, EditHandler, instance=True) - - def test_field_type(self): - result = self.edit_handler.field_type() - self.assertEqual(result, "") - - def test_render_as_object(self): - result = self.edit_handler.render_as_object() - self.assertEqual(result, "foo") - - def test_render_as_field(self): - result = self.edit_handler.render_as_field() - self.assertEqual(result, "foo") - - def test_render_js(self): - result = self.edit_handler.render_js() - self.assertEqual(result, "") - - def test_rendered_fields(self): - result = self.edit_handler.rendered_fields() - self.assertEqual(result, []) - - def test_render_missing_fields(self): - fake_form = self.FakeForm() - fake_form["foo"] = "bar" - self.edit_handler.form = fake_form - self.assertEqual(self.edit_handler.render_missing_fields(), "bar") - - def test_render_form_content(self): - fake_form = self.FakeForm() - fake_form["foo"] = "bar" - self.edit_handler.form = fake_form - self.assertEqual(self.edit_handler.render_form_content(), "foobar") - - class TestTabbedInterface(TestCase): class FakeChild(object): class FakeGrandchild(object): @@ -183,34 +119,10 @@ class TestObjectList(TestCase): self.assertTrue(issubclass(object_list, BaseObjectList)) -class TestBaseFieldPanel(TestCase): - class FakeClass(object): - required = False - - class FakeField(object): - label = 'label' - help_text = 'help text' - - def setUp(self): - fake_field = self.FakeField() - fake_base_field_panel = type('_FieldPanel', - (BaseFieldPanel,), - {'field_name': 'barbecue'}) - self.base_field_panel = fake_base_field_panel( - instance=True, - form={'barbecue': fake_field}) - - def test_field_type(self): - fake_object = self.FakeClass() - another_fake_object = self.FakeClass() - fake_object.field = another_fake_object - self.base_field_panel.bound_field = fake_object - self.assertEqual(self.base_field_panel.field_type(), 'fake_class') - - class TestFieldPanel(TestCase): class FakeClass(object): required = False + widget = 'fake widget' class FakeField(object): label = 'label' @@ -218,6 +130,14 @@ class TestFieldPanel(TestCase): errors = ['errors'] id_for_label = 'id for label' + class FakeForm(dict): + def __init__(self, *args, **kwargs): + self.fields = self.fields_iterator() + + def fields_iterator(self): + for i in self: + yield i + def setUp(self): fake_field = self.FakeField() fake_field.field = self.FakeClass() @@ -258,6 +178,41 @@ class TestFieldPanel(TestCase): result = self.field_panel.rendered_fields() self.assertEqual(result, ['barbecue']) + def test_field_type(self): + fake_object = self.FakeClass() + another_fake_object = self.FakeClass() + fake_object.field = another_fake_object + self.field_panel.bound_field = fake_object + self.assertEqual(self.field_panel.field_type(), 'fake_class') + + def test_widget_overrides(self): + result = self.field_panel.widget_overrides() + self.assertEqual(result, {}) + + def test_required_formsets(self): + result = self.field_panel.required_formsets() + self.assertEqual(result, []) + + def test_get_form_class(self): + result = self.field_panel.get_form_class(Page) + self.assertTrue(issubclass(result, WagtailAdminModelForm)) + + def test_render_js(self): + result = self.field_panel.render_js() + self.assertEqual(result, "") + + def test_render_missing_fields(self): + fake_form = self.FakeForm() + fake_form["foo"] = "bar" + self.field_panel.form = fake_form + self.assertEqual(self.field_panel.render_missing_fields(), "bar") + + def test_render_form_content(self): + fake_form = self.FakeForm() + fake_form["foo"] = "bar" + self.field_panel.form = fake_form + self.assertIn("bar", self.field_panel.render_form_content()) + class TestRichTextFieldPanel(TestCase): class FakeField(object): From e1721030dcb8391617af28c3e4f04503862eb1b1 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 17:13:34 +0100 Subject: [PATCH 025/154] Exclude filter fields from _all. Explicitly include search fields in _all --- wagtail/wagtailsearch/backends/elasticsearch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 7ffebd045..5e9e6e984 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -54,8 +54,11 @@ class ElasticSearchMapping(object): if field.partial_match: mapping['analyzer'] = 'edgengram_analyzer' + + mapping['include_in_all'] = True elif isinstance(field, FilterField): mapping['index'] = 'not_analyzed' + mapping['include_in_all'] = False if 'es_extra' in field.kwargs: for key, value in field.kwargs['es_extra'].items(): @@ -66,8 +69,8 @@ class ElasticSearchMapping(object): def get_mapping(self): # Make field list fields = { - 'pk': dict(type='string', index='not_analyzed', store='yes'), - 'content_type': dict(type='string', index='not_analyzed'), + 'pk': dict(type='string', index='not_analyzed', store='yes', include_in_all=False), + 'content_type': dict(type='string', index='not_analyzed', include_in_all=False), } fields.update(dict( From ca1fa1d93341f21f60d0fdde943c062f5c4b343c Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 20 Jun 2014 17:32:06 +0100 Subject: [PATCH 026/154] Index partials together into '_partials' --- wagtail/wagtailsearch/backends/elasticsearch.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 5e9e6e984..8fc2950dd 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -71,6 +71,7 @@ class ElasticSearchMapping(object): fields = { 'pk': dict(type='string', index='not_analyzed', store='yes', include_in_all=False), 'content_type': dict(type='string', index='not_analyzed', include_in_all=False), + '_partials': dict(type='string', analyzer='edgengram_analyzer', include_in_all=False), } fields.update(dict( @@ -89,8 +90,18 @@ class ElasticSearchMapping(object): def get_document(self, obj): # Build document doc = dict(pk=str(obj.pk), content_type=self.model.indexed_get_content_type()) + partials = [] for field in self.model.get_search_fields(): - doc[field.get_index_name(self.model)] = field.get_value(obj) + value = field.get_value(obj) + + doc[field.get_index_name(self.model)] = value + + # Check if this field should be added into _partials + if isinstance(field, SearchField) and field.partial_match: + partials.append(value) + + # Add partials to document + doc['_partials'] = partials return doc @@ -102,7 +113,7 @@ class ElasticSearchQuery(object): def __init__(self, model, query_string, fields=None, filters={}): self.model = model self.query_string = query_string - self.fields = fields or ['_all'] + self.fields = fields or ['_all', '_partials'] self.filters = filters def _get_filters(self): From b15522ac1e08dd18af9a9988f619eed9e0e22f20 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 15:49:19 +0100 Subject: [PATCH 027/154] Implemented search on queryset --- .../wagtailsearch/backends/elasticsearch.py | 204 ++++++++++++------ wagtail/wagtailsearch/indexed.py | 9 +- wagtail/wagtailsearch/models.py | 2 +- 3 files changed, 146 insertions(+), 69 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 8fc2950dd..a8c590de2 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -3,6 +3,7 @@ from __future__ import absolute_import import json from django.db import models +from django.db.models.query import QuerySet from elasticsearch import Elasticsearch, NotFoundError, RequestError from elasticsearch.helpers import bulk @@ -109,12 +110,123 @@ class ElasticSearchMapping(object): return '' % (self.model.__name__, ) +class FilterError(Exception): + pass + + +class FieldError(Exception): + pass + + class ElasticSearchQuery(object): - def __init__(self, model, query_string, fields=None, filters={}): - self.model = model + def __init__(self, queryset, query_string, fields=None): + self.queryset = queryset self.query_string = query_string - self.fields = fields or ['_all', '_partials'] - self.filters = filters + self.fields = fields or ['_all', 'partials'] + + def _get_filters_from_where(self, where_node): + # Check if this is a leaf node + if isinstance(where_node, tuple): + field_name = where_node[0].col + lookup = where_node[1] + value = where_node[3] + + # Get field + field = dict( + (field.get_attname(self.queryset.model), field) + for field in self.queryset.model.get_filterable_search_fields() + ).get(field_name, None) + + # Give error if the field doesn't exist + if field is None: + raise FieldError('Cannot filter ElasticSearch results with field "' + field_name + '". Please add FilterField(\'' + field_name + '\') to ' + self.queryset.model.__name__ + '.search_fields.') + + # Get the name of the field in the index + field_index_name = field.get_index_name(self.queryset.model) + + # Find lookup + if lookup == 'exact': + if value is None: + return { + 'missing': { + 'field': field_index_name, + } + } + else: + return { + 'term': { + field_index_name: value, + } + } + + if lookup == 'isnull': + if value: + return { + 'missing': { + 'field': field_index_name, + } + } + else: + return { + 'not': { + 'missing': { + 'field': field_index_name, + } + } + } + + if lookup in ['startswith', 'prefix']: + return { + 'prefix': { + field_index_name: value, + } + } + + if lookup in ['gt', 'gte', 'lt', 'lte']: + return { + 'range': { + field_index_name: { + lookup: value, + } + } + } + + if lookup == 'range': + lower, upper = value + + return { + 'range': { + field_index_name: { + 'gte': lower, + 'lte': upper, + } + } + } + + raise FilterError('Could not apply filter on ElasticSearch results "' + field_name + '__' + lookup + ' = ' + unicode(value) + '". Lookup "' + lookup + '"" not recognosed.') + + # Get child filters + connector = where_node.connector + child_filters = [self._get_filters_from_where(child) for child in where_node.children] + child_filters = [child_filter for child_filter in child_filters if child_filter] + + # Connect them + if child_filters: + if len(child_filters) == 1: + filter_out = child_filters[0] + else: + filter_out = { + connector.lower(): [ + fil for fil in child_filters if fil is not None + ] + } + + if where_node.negated: + filter_out = { + 'not': filter_out + } + + return filter_out def _get_filters(self): # Filters @@ -123,59 +235,14 @@ class ElasticSearchQuery(object): # Filter by content type filters.append({ 'prefix': { - 'content_type': self.model.indexed_get_content_type() + 'content_type': self.queryset.model.indexed_get_content_type() } }) - # Extra filters - if self.filters: - for key, value in self.filters.items(): - if '__' in key: - field, lookup = key.split('__') - else: - field = key - lookup = None - - if lookup is None: - if value is None: - filters.append({ - 'missing': { - 'field': field, - } - }) - else: - filters.append({ - 'term': { - field: value - } - }) - - if lookup in ['startswith', 'prefix']: - filters.append({ - 'prefix': { - field: value - } - }) - - if lookup in ['gt', 'gte', 'lt', 'lte']: - filters.append({ - 'range': { - field: { - lookup: value, - } - } - }) - - if lookup == 'range': - lower, upper = value - filters.append({ - 'range': { - field: { - 'gte': lower, - 'lte': upper, - } - } - }) + # Apply filters from queryset + queryset_filters = self._get_filters_from_where(self.queryset.query.where) + if queryset_filters: + filters.append(queryset_filters) return filters @@ -263,15 +330,8 @@ class ElasticSearchResults(object): # Initialise results dictionary results = dict((str(pk), None) for pk in pks) - # Get queryset - queryset = self.query.model.objects.filter(pk__in=pks) - - # Add prefetch related - if self.prefetch_related: - for prefetch in self.prefetch_related: - queryset = queryset.prefetch_related(prefetch) - # Find objects in database and add them to dict + queryset = self.query.queryset.filter(pk__in=pks) for obj in queryset: results[str(obj.pk)] = obj @@ -502,7 +562,15 @@ class ElasticSearch(BaseSearch): except NotFoundError: pass # Document doesn't exist, ignore this exception - def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]): + def search(self, query_string, model_or_queryset, fields=None, filters={}, prefetch_related=[]): + # Find model/queryset + if isinstance(model_or_queryset, QuerySet): + model = model_or_queryset.model + queryset = model_or_queryset + else: + model = model_or_queryset + queryset = model_or_queryset.objects.all() + # Model must be a descendant of Indexed and be a django model if not issubclass(model, Indexed) or not issubclass(model, models.Model): return [] @@ -514,5 +582,13 @@ class ElasticSearch(BaseSearch): if not query_string: return [] + # Apply filters to queryset + if filters: + queryset = queryset.filter(**filters) + + # Prefetch related + for prefetch in prefetch_related: + queryset = queryset.prefetch_related(prefetch) + # Return search results - return ElasticSearchResults(self, ElasticSearchQuery(model, query_string, fields=fields, filters=filters), prefetch_related=prefetch_related) + return ElasticSearchResults(self, ElasticSearchQuery(queryset, query_string, fields=fields)) diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index b290f440b..a2cb54d14 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -112,13 +112,16 @@ class Indexed(object): @classmethod def get_searchable_search_fields(cls): - return filter(lambda field: field.searchable, cls.get_search_fields()) + return filter(lambda field: isinstance(field, SearchField), cls.get_search_fields()) + + @classmethod + def get_filterable_search_fields(cls): + return filter(lambda field: isinstance(field, FilterField), cls.get_search_fields()) indexed_fields = () class BaseField(object): - searchable = False suffix = '' def __init__(self, field_name, **kwargs): @@ -163,8 +166,6 @@ class BaseField(object): class SearchField(BaseField): - searchable = True - def __init__(self, field_name, boost=None, partial_match=False, **kwargs): super(SearchField, self).__init__(field_name, **kwargs) self.boost = boost diff --git a/wagtail/wagtailsearch/models.py b/wagtail/wagtailsearch/models.py index 509368463..7cb8546d7 100644 --- a/wagtail/wagtailsearch/models.py +++ b/wagtail/wagtailsearch/models.py @@ -91,7 +91,7 @@ class SearchTest(models.Model, indexed.Indexed): indexed.SearchField('title'), indexed.SearchField('content'), indexed.SearchField('callable_indexed_field'), - indexed.SearchField('live'), + indexed.FilterField('live'), ) def callable_indexed_field(self): From 41199dd576ee9a4ad3ed5cbf414e655499bb2088 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 15:51:29 +0100 Subject: [PATCH 028/154] Minor optimisation in ElasticSearchQuery --- .../wagtailsearch/backends/elasticsearch.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index a8c590de2..649a7a7f0 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -260,15 +260,24 @@ class ElasticSearchQuery(object): # Filters filters = self._get_filters() - - return { - 'filtered': { - 'query': query, - 'filter': { - 'and': filters, + if len(filters) == 1: + query = { + 'filtered': { + 'query': query, + 'filter': filters[0], } } - } + elif len(filters) > 1: + query = { + 'filtered': { + 'query': query, + 'filter': { + 'and': filters, + } + } + } + + return query def __repr__(self): return json.dumps(self.to_es()) From d872768baffe4f6aa2e66ad92a82f1a3f075d69e Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 16:01:04 +0100 Subject: [PATCH 029/154] Allow query_string to be set to None This allows match_all queries to be created --- wagtail/wagtailsearch/backends/db.py | 47 ++++++++++--------- .../wagtailsearch/backends/elasticsearch.py | 24 ++++++---- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/wagtail/wagtailsearch/backends/db.py b/wagtail/wagtailsearch/backends/db.py index 4c05b19df..c91363740 100644 --- a/wagtail/wagtailsearch/backends/db.py +++ b/wagtail/wagtailsearch/backends/db.py @@ -28,42 +28,43 @@ class DBSearch(BaseSearch): pass # Not needed def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]): - # Normalise query string - query_string = normalise_query_string(query_string) - - # Get terms - terms = query_string.split() - if not terms: - return model.objects.none() - # Get fields if fields is None: fields = [field.field_name for field in model.get_searchable_search_fields()] - # Start will all objects + # Start with all objects query = model.objects.all() # Apply filters if filters: query = query.filter(**filters) - # Filter by terms - for term in terms: - term_query = models.Q() - for field_name in fields: - # Check if the field exists (this will filter out indexed callables) - try: - model._meta.get_field_by_name(field_name) - except: - continue + if query_string is not None: + # Normalise query string + query_string = normalise_query_string(query_string) - # Filter on this field - term_query |= models.Q(**{'%s__icontains' % field_name: term}) + # Get terms + terms = query_string.split() + if not terms: + return model.objects.none() - query = query.filter(term_query) + # Filter by terms + for term in terms: + term_query = models.Q() + for field_name in fields: + # Check if the field exists (this will filter out indexed callables) + try: + model._meta.get_field_by_name(field_name) + except: + continue - # Distinct - query = query.distinct() + # Filter on this field + term_query |= models.Q(**{'%s__icontains' % field_name: term}) + + query = query.filter(term_query) + + # Distinct + query = query.distinct() # Prefetch related if prefetch_related: diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 649a7a7f0..af810b02b 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -248,15 +248,20 @@ class ElasticSearchQuery(object): def to_es(self): # Query - query = { - 'query_string': { - 'query': self.query_string, + if self.query_string is not None: + query = { + 'query_string': { + 'query': self.query_string, + } } - } - # Fields - if self.fields: - query['query_string']['fields'] = self.fields + # Fields + if self.fields: + query['query_string']['fields'] = self.fields + else: + query = { + 'match_all': {} + } # Filters filters = self._get_filters() @@ -585,10 +590,11 @@ class ElasticSearch(BaseSearch): return [] # Normalise query string - query_string = normalise_query_string(query_string) + if query_string is not None: + query_string = normalise_query_string(query_string) # Check that theres still a query string after the clean up - if not query_string: + if query_string == "": return [] # Apply filters to queryset From 156eb89850e1efdcdc2b6fc31fb74681441098dd Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 16:10:50 +0100 Subject: [PATCH 030/154] Changed default values of filters and prefetch_related to None --- wagtail/wagtailsearch/backends/db.py | 2 +- wagtail/wagtailsearch/backends/elasticsearch.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/wagtail/wagtailsearch/backends/db.py b/wagtail/wagtailsearch/backends/db.py index c91363740..56536e343 100644 --- a/wagtail/wagtailsearch/backends/db.py +++ b/wagtail/wagtailsearch/backends/db.py @@ -27,7 +27,7 @@ class DBSearch(BaseSearch): def delete(self, obj): pass # Not needed - def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]): + def search(self, query_string, model, fields=None, filters=None, prefetch_related=None): # Get fields if fields is None: fields = [field.field_name for field in model.get_searchable_search_fields()] diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index af810b02b..14b1a3bdb 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -576,7 +576,7 @@ class ElasticSearch(BaseSearch): except NotFoundError: pass # Document doesn't exist, ignore this exception - def search(self, query_string, model_or_queryset, fields=None, filters={}, prefetch_related=[]): + def search(self, query_string, model_or_queryset, fields=None, filters=None, prefetch_related=None): # Find model/queryset if isinstance(model_or_queryset, QuerySet): model = model_or_queryset.model @@ -602,8 +602,9 @@ class ElasticSearch(BaseSearch): queryset = queryset.filter(**filters) # Prefetch related - for prefetch in prefetch_related: - queryset = queryset.prefetch_related(prefetch) + if prefetch_related: + for prefetch in prefetch_related: + queryset = queryset.prefetch_related(prefetch) # Return search results return ElasticSearchResults(self, ElasticSearchQuery(queryset, query_string, fields=fields)) From 8a99a63cf1df057e126d6a4d841f8af05ad97644 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 17:37:18 +0100 Subject: [PATCH 031/154] Fixed typo --- wagtail/wagtailsearch/backends/elasticsearch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 14b1a3bdb..ea0413eed 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -122,7 +122,7 @@ class ElasticSearchQuery(object): def __init__(self, queryset, query_string, fields=None): self.queryset = queryset self.query_string = query_string - self.fields = fields or ['_all', 'partials'] + self.fields = fields or ['_all', '_partials'] def _get_filters_from_where(self, where_node): # Check if this is a leaf node From 2100c6372d0761a920cf22c62dffb9ed7d58c7b6 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 16:45:01 +0100 Subject: [PATCH 032/154] Moved wagtailsearch test models into wagtail.tests models --- wagtail/tests/models.py | 25 ++++ ...to__del_searchtestchild__del_searchtest.py | 112 ++++++++++++++++++ wagtail/wagtailsearch/models.py | 26 ---- wagtail/wagtailsearch/tests/test_backends.py | 4 +- 4 files changed, 139 insertions(+), 28 deletions(-) create mode 100644 wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py diff --git a/wagtail/tests/models.py b/wagtail/tests/models.py index ff6dc2dcc..5787c19ff 100644 --- a/wagtail/tests/models.py +++ b/wagtail/tests/models.py @@ -11,6 +11,7 @@ from wagtail.wagtailimages.edit_handlers import ImageChooserPanel from wagtail.wagtaildocs.edit_handlers import DocumentChooserPanel from wagtail.wagtailforms.models import AbstractEmailForm, AbstractFormField from wagtail.wagtailsnippets.models import register_snippet +from wagtail.wagtailsearch import indexed EVENT_AUDIENCE_CHOICES = ( @@ -316,3 +317,27 @@ class BusinessSubIndex(Page): class BusinessChild(Page): subpage_types = [] + + +class SearchTest(models.Model, indexed.Indexed): + title = models.CharField(max_length=255) + content = models.TextField() + live = models.BooleanField(default=False) + + search_fields = ( + indexed.SearchField('title'), + indexed.SearchField('content'), + indexed.SearchField('callable_indexed_field'), + indexed.FilterField('live'), + ) + + def callable_indexed_field(self): + return "Callable" + + +class SearchTestChild(SearchTest): + extra_content = models.TextField() + + search_fields = SearchTest.search_fields + ( + indexed.SearchField('extra_content'), + ) diff --git a/wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py b/wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py new file mode 100644 index 000000000..560dbaae9 --- /dev/null +++ b/wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Deleting model 'SearchTestChild' + db.delete_table(u'wagtailsearch_searchtestchild') + + # Deleting model 'SearchTest' + db.delete_table(u'wagtailsearch_searchtest') + + + def backwards(self, orm): + # Adding model 'SearchTestChild' + db.create_table(u'wagtailsearch_searchtestchild', ( + ('extra_content', self.gf('django.db.models.fields.TextField')()), + (u'searchtest_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['wagtailsearch.SearchTest'], unique=True, primary_key=True)), + )) + db.send_create_signal(u'wagtailsearch', ['SearchTestChild']) + + # Adding model 'SearchTest' + db.create_table(u'wagtailsearch_searchtest', ( + ('content', self.gf('django.db.models.fields.TextField')()), + ('live', self.gf('django.db.models.fields.BooleanField')(default=False)), + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('title', self.gf('django.db.models.fields.CharField')(max_length=255)), + )) + db.send_create_signal(u'wagtailsearch', ['SearchTest']) + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'wagtailcore.page': { + 'Meta': {'object_name': 'Page'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"orm['contenttypes.ContentType']"}), + 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': u"orm['auth.User']"}), + 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'show_in_menus': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) + }, + u'wagtailsearch.editorspick': { + 'Meta': {'ordering': "('sort_order',)", 'object_name': 'EditorsPick'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['wagtailcore.Page']"}), + 'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'editors_picks'", 'to': u"orm['wagtailsearch.Query']"}), + 'sort_order': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + u'wagtailsearch.query': { + 'Meta': {'object_name': 'Query'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'query_string': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}) + }, + u'wagtailsearch.querydailyhits': { + 'Meta': {'unique_together': "(('query', 'date'),)", 'object_name': 'QueryDailyHits'}, + 'date': ('django.db.models.fields.DateField', [], {}), + 'hits': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'daily_hits'", 'to': u"orm['wagtailsearch.Query']"}) + } + } + + complete_apps = ['wagtailsearch'] \ No newline at end of file diff --git a/wagtail/wagtailsearch/models.py b/wagtail/wagtailsearch/models.py index 7cb8546d7..c63a076a7 100644 --- a/wagtail/wagtailsearch/models.py +++ b/wagtail/wagtailsearch/models.py @@ -78,29 +78,3 @@ class EditorsPick(models.Model): class Meta: ordering = ('sort_order', ) - - -# Used for tests - -class SearchTest(models.Model, indexed.Indexed): - title = models.CharField(max_length=255) - content = models.TextField() - live = models.BooleanField(default=False) - - search_fields = ( - indexed.SearchField('title'), - indexed.SearchField('content'), - indexed.SearchField('callable_indexed_field'), - indexed.FilterField('live'), - ) - - def callable_indexed_field(self): - return "Callable" - - -class SearchTestChild(SearchTest): - extra_content = models.TextField() - - search_fields = SearchTest.search_fields + ( - indexed.SearchField('extra_content'), - ) diff --git a/wagtail/wagtailsearch/tests/test_backends.py b/wagtail/wagtailsearch/tests/test_backends.py index 86a67f21a..04d28689a 100644 --- a/wagtail/wagtailsearch/tests/test_backends.py +++ b/wagtail/wagtailsearch/tests/test_backends.py @@ -6,9 +6,9 @@ from django.conf import settings from django.core import management from wagtail.tests.utils import unittest -from wagtail.wagtailsearch import models, get_search_backend +from wagtail.tests import models +from wagtail.wagtailsearch.backends import get_search_backend, InvalidSearchBackendError from wagtail.wagtailsearch.backends.db import DBSearch -from wagtail.wagtailsearch.backends import InvalidSearchBackendError class BackendTests(object): From 2679dc2fe69fbf08423d727c19e16a19c068ffbb Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 16:51:06 +0100 Subject: [PATCH 033/154] Split up backend tests --- wagtail/wagtailsearch/tests/test_backends.py | 21 ------------------- .../wagtailsearch/tests/test_db_backend.py | 13 ++++++++++++ .../tests/test_elasticsearch_backend.py | 18 ++++++++++++++++ 3 files changed, 31 insertions(+), 21 deletions(-) create mode 100644 wagtail/wagtailsearch/tests/test_db_backend.py create mode 100644 wagtail/wagtailsearch/tests/test_elasticsearch_backend.py diff --git a/wagtail/wagtailsearch/tests/test_backends.py b/wagtail/wagtailsearch/tests/test_backends.py index 04d28689a..e9b656298 100644 --- a/wagtail/wagtailsearch/tests/test_backends.py +++ b/wagtail/wagtailsearch/tests/test_backends.py @@ -151,27 +151,6 @@ class BackendTests(object): self.assertEqual(len(results), 3) -class TestDBBackend(BackendTests, TestCase): - backend_path = 'wagtail.wagtailsearch.backends.db.DBSearch' - - @unittest.expectedFailure - def test_callable_indexed_field(self): - super(TestDBBackend, self).test_callable_indexed_field() - - -class TestElasticSearchBackend(BackendTests, TestCase): - backend_path = 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch' - - def test_search_with_spaces_only(self): - # Search for some space characters and hope it doesn't crash - results = self.backend.search(" ", models.SearchTest) - - # Queries are lazily evaluated, force it to run - list(results) - - # Didn't crash, yay! - - @override_settings(WAGTAILSEARCH_BACKENDS={ 'default': {'BACKEND': 'wagtail.wagtailsearch.backends.db.DBSearch'} }) diff --git a/wagtail/wagtailsearch/tests/test_db_backend.py b/wagtail/wagtailsearch/tests/test_db_backend.py new file mode 100644 index 000000000..f471a34d1 --- /dev/null +++ b/wagtail/wagtailsearch/tests/test_db_backend.py @@ -0,0 +1,13 @@ +from wagtail.tests.utils import unittest + +from django.test import TestCase + +from .test_backends import BackendTests + + +class TestDBBackend(BackendTests, TestCase): + backend_path = 'wagtail.wagtailsearch.backends.db.DBSearch' + + @unittest.expectedFailure + def test_callable_indexed_field(self): + super(TestDBBackend, self).test_callable_indexed_field() diff --git a/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py b/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py new file mode 100644 index 000000000..c2623fd54 --- /dev/null +++ b/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py @@ -0,0 +1,18 @@ +from wagtail.tests.utils import unittest + +from django.test import TestCase + +from .test_backends import BackendTests + + +class TestElasticSearchBackend(BackendTests, TestCase): + backend_path = 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch' + + def test_search_with_spaces_only(self): + # Search for some space characters and hope it doesn't crash + results = self.backend.search(" ", models.SearchTest) + + # Queries are lazily evaluated, force it to run + list(results) + + # Didn't crash, yay! From f02a6937d262dcfa68ceb8e0551663799c7f9b17 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 17:31:52 +0100 Subject: [PATCH 034/154] Added more tests for ElasticSearch backend --- wagtail/tests/models.py | 15 +- .../tests/test_elasticsearch_backend.py | 347 ++++++++++++++++++ 2 files changed, 357 insertions(+), 5 deletions(-) diff --git a/wagtail/tests/models.py b/wagtail/tests/models.py index 5787c19ff..86c85e7f9 100644 --- a/wagtail/tests/models.py +++ b/wagtail/tests/models.py @@ -323,21 +323,26 @@ class SearchTest(models.Model, indexed.Indexed): title = models.CharField(max_length=255) content = models.TextField() live = models.BooleanField(default=False) + published_date = models.DateField(null=True) - search_fields = ( - indexed.SearchField('title'), + search_fields = [ + indexed.SearchField('title', partial_match=True), indexed.SearchField('content'), indexed.SearchField('callable_indexed_field'), + indexed.FilterField('title'), indexed.FilterField('live'), - ) + indexed.FilterField('published_date'), + ] def callable_indexed_field(self): return "Callable" class SearchTestChild(SearchTest): + subtitle = models.CharField(max_length=255, null=True, blank=True) extra_content = models.TextField() - search_fields = SearchTest.search_fields + ( + search_fields = SearchTest.search_fields + [ + indexed.SearchField('subtitle', partial_match=True), indexed.SearchField('extra_content'), - ) + ] diff --git a/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py b/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py index c2623fd54..88aa87f8b 100644 --- a/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py +++ b/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py @@ -1,7 +1,11 @@ from wagtail.tests.utils import unittest +import datetime +import json from django.test import TestCase +from django.db.models import Q +from wagtail.tests import models from .test_backends import BackendTests @@ -16,3 +20,346 @@ class TestElasticSearchBackend(BackendTests, TestCase): list(results) # Didn't crash, yay! + + def test_partial_search(self): + # Reset the index + self.backend.reset_index() + self.backend.add_type(models.SearchTest) + self.backend.add_type(models.SearchTestChild) + + # Add some test data + obj = models.SearchTest() + obj.title = "HelloWorld" + obj.live = True + obj.save() + self.backend.add(obj) + + # Refresh the index + self.backend.refresh_index() + + # Search and check + results = self.backend.search("HelloW", models.SearchTest.objects.all()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].id, obj.id) + + def test_child_partial_search(self): + # Reset the index + self.backend.reset_index() + self.backend.add_type(models.SearchTest) + self.backend.add_type(models.SearchTestChild) + + obj = models.SearchTestChild() + obj.title = "WorldHello" + obj.subtitle = "HelloWorld" + obj.live = True + obj.save() + self.backend.add(obj) + + # Refresh the index + self.backend.refresh_index() + + # Search and check + results = self.backend.search("HelloW", models.SearchTest.objects.all()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].id, obj.id) + + +class TestElasticSearchQuery(TestCase): + def assertDictEqual(self, a, b): + default = self.JSONSerializer().default + self.assertEqual(json.dumps(a, sort_keys=True, default=default), json.dumps(b, sort_keys=True, default=default)) + + def setUp(self): + # Import using a try-catch block to prevent crashes if the elasticsearch-py + # module is not installed + try: + from wagtail.wagtailsearch.backends.elasticsearch import ElasticSearchQuery + from elasticsearch.serializer import JSONSerializer + except ImportError: + raise unittest.SkipTest("elasticsearch-py not installed") + + self.ElasticSearchQuery = ElasticSearchQuery + self.JSONSerializer = JSONSerializer + + def test_simple(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.all(), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'prefix': {'content_type': 'tests_searchtest'}}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_none_query_string(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.all(), None) + + # Check it + expected_result = {'filtered': {'filter': {'prefix': {'content_type': 'tests_searchtest'}}, 'query': {'match_all': {}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_filter(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title="Test"), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'term': {'title_filter': 'Test'}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_and_filter(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title="Test", live=True), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'and': [{'term': {'live_filter': True}}, {'term': {'title_filter': 'Test'}}]}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_or_filter(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(Q(title="Test") | Q(live=True)), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'or': [{'term': {'title_filter': 'Test'}}, {'term': {'live_filter': True}}]}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_negated_filter(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.exclude(live=True), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'not': {'term': {'live_filter': True}}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_fields(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.all(), "Hello", fields=['title']) + + # Check it + expected_result = {'filtered': {'filter': {'prefix': {'content_type': 'tests_searchtest'}}, 'query': {'query_string': {'query': 'Hello', 'fields': ['title']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_exact_lookup(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__exact="Test"), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'term': {'title_filter': 'Test'}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_none_lookup(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title=None), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'missing': {'field': 'title_filter'}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_isnull_true_lookup(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__isnull=True), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'missing': {'field': 'title_filter'}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_isnull_false_lookup(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__isnull=False), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'not': {'missing': {'field': 'title_filter'}}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_startswith_lookup(self): + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__startswith="Test"), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'prefix': {'title_filter': 'Test'}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_gt_lookup(self): + # This shares the same code path as gte, lt and lte so theres no need to test those + # This also tests conversion of python dates to strings + + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(published_date__gt=datetime.datetime(2014, 4, 29)), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'range': {'published_date_filter': {'gt': '2014-04-29'}}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + def test_range_lookup(self): + start_date = datetime.datetime(2014, 4, 29) + end_date = datetime.datetime(2014, 8, 19) + + # Create a query + query = self.ElasticSearchQuery(models.SearchTest.objects.filter(published_date__range=(start_date, end_date)), "Hello") + + # Check it + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'range': {'published_date_filter': {'gte': '2014-04-29', 'lte': '2014-08-19'}}}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query.to_es(), expected_result) + + +class TestElasticSearchMapping(TestCase): + def assertDictEqual(self, a, b): + default = self.JSONSerializer().default + self.assertEqual(json.dumps(a, sort_keys=True, default=default), json.dumps(b, sort_keys=True, default=default)) + + def setUp(self): + # Import using a try-catch block to prevent crashes if the elasticsearch-py + # module is not installed + try: + from wagtail.wagtailsearch.backends.elasticsearch import ElasticSearchMapping + from elasticsearch.serializer import JSONSerializer + except ImportError: + raise unittest.SkipTest("elasticsearch-py not installed") + + self.JSONSerializer = JSONSerializer + + # Create ES mapping + self.es_mapping = ElasticSearchMapping(models.SearchTest) + + # Create ES document + self.obj = models.SearchTest(title="Hello") + self.obj.save() + + def test_get_document_type(self): + self.assertEqual(self.es_mapping.get_document_type(), 'tests_searchtest') + + def test_get_mapping(self): + # Build mapping + mapping = self.es_mapping.get_mapping() + + # Check + expected_result = { + 'tests_searchtest': { + 'properties': { + 'pk': {'index': 'not_analyzed', 'type': 'string', 'store': 'yes', 'include_in_all': False}, + 'content_type': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, + '_partials': {'analyzer': 'edgengram_analyzer', 'include_in_all': False, 'type': 'string'}, + 'live_filter': {'index': 'not_analyzed', 'type': 'boolean', 'include_in_all': False}, + 'published_date_filter': {'index': 'not_analyzed', 'type': 'date', 'include_in_all': False}, + 'title': {'type': 'string', 'include_in_all': True, 'analyzer': 'edgengram_analyzer'}, + 'title_filter': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, + 'content': {'type': 'string', 'include_in_all': True}, + 'callable_indexed_field': {'type': 'string', 'include_in_all': True} + } + } + } + + self.assertDictEqual(mapping, expected_result) + + def test_get_document_id(self): + self.assertEqual(self.es_mapping.get_document_id(self.obj), 'tests_searchtest:' + str(self.obj.pk)) + + def test_get_document(self): + # Get document + document = self.es_mapping.get_document(self.obj) + + # Check + expected_result = { + 'pk': str(self.obj.pk), + 'content_type': 'tests_searchtest', + '_partials': ['Hello'], + 'live_filter': False, + 'published_date_filter': None, + 'title': 'Hello', + 'title_filter': 'Hello', + 'callable_indexed_field': 'Callable', + 'content': '', + } + + self.assertDictEqual(document, expected_result) + + +class TestElasticSearchMappingInheritance(TestCase): + def assertDictEqual(self, a, b): + default = self.JSONSerializer().default + self.assertEqual(json.dumps(a, sort_keys=True, default=default), json.dumps(b, sort_keys=True, default=default)) + + def setUp(self): + # Import using a try-catch block to prevent crashes if the elasticsearch-py + # module is not installed + try: + from wagtail.wagtailsearch.backends.elasticsearch import ElasticSearchMapping + from elasticsearch.serializer import JSONSerializer + except ImportError: + raise unittest.SkipTest("elasticsearch-py not installed") + + self.JSONSerializer = JSONSerializer + + # Create ES mapping + self.es_mapping = ElasticSearchMapping(models.SearchTestChild) + + # Create ES document + self.obj = models.SearchTestChild(title="Hello", subtitle="World") + self.obj.save() + + def test_get_document_type(self): + self.assertEqual(self.es_mapping.get_document_type(), 'tests_searchtest_tests_searchtestchild') + + def test_get_mapping(self): + # Build mapping + mapping = self.es_mapping.get_mapping() + + # Check + expected_result = { + 'tests_searchtest_tests_searchtestchild': { + 'properties': { + # New + 'extra_content': {'type': 'string', 'include_in_all': True}, + 'subtitle': {'type': 'string', 'include_in_all': True, 'analyzer': 'edgengram_analyzer'}, + + # Inherited + 'pk': {'index': 'not_analyzed', 'type': 'string', 'store': 'yes', 'include_in_all': False}, + 'content_type': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, + '_partials': {'analyzer': 'edgengram_analyzer', 'include_in_all': False, 'type': 'string'}, + 'live_filter': {'index': 'not_analyzed', 'type': 'boolean', 'include_in_all': False}, + 'published_date_filter': {'index': 'not_analyzed', 'type': 'date', 'include_in_all': False}, + 'title': {'type': 'string', 'include_in_all': True, 'analyzer': 'edgengram_analyzer'}, + 'title_filter': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, + 'content': {'type': 'string', 'include_in_all': True}, + 'callable_indexed_field': {'type': 'string', 'include_in_all': True} + } + } + } + + self.assertDictEqual(mapping, expected_result) + + def test_get_document_id(self): + # This must be tests_searchtest instead of 'tests_searchtest_tests_searchtestchild' + # as it uses the contents base content type name. + # This prevents the same object being accidentally indexed twice. + self.assertEqual(self.es_mapping.get_document_id(self.obj), 'tests_searchtest:' + str(self.obj.pk)) + + def test_get_document(self): + # Build document + document = self.es_mapping.get_document(self.obj) + + # Sort partials + if '_partials' in document: + document['_partials'].sort() + + # Check + expected_result = { + # New + 'extra_content': '', + 'subtitle': 'World', + + # Changed + 'content_type': 'tests_searchtest_tests_searchtestchild', + + # Inherited + 'pk': str(self.obj.pk), + '_partials': ['Hello', 'World'], + 'live_filter': False, + 'published_date_filter': None, + 'title': 'Hello', + 'title_filter': 'Hello', + 'callable_indexed_field': 'Callable', + 'content': '', + } + + self.assertDictEqual(document, expected_result) From 04c507e37be80b9ba5b88fdc6140acdad43f3ed3 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 18:25:42 +0100 Subject: [PATCH 035/154] Added tests for indexed class --- wagtail/tests/models.py | 28 +++++++++++ .../wagtailsearch/tests/test_indexed_class.py | 49 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 wagtail/wagtailsearch/tests/test_indexed_class.py diff --git a/wagtail/tests/models.py b/wagtail/tests/models.py index 86c85e7f9..504648933 100644 --- a/wagtail/tests/models.py +++ b/wagtail/tests/models.py @@ -346,3 +346,31 @@ class SearchTestChild(SearchTest): indexed.SearchField('subtitle', partial_match=True), indexed.SearchField('extra_content'), ] + + +class SearchTestOldConfig(models.Model, indexed.Indexed): + """ + This tests that the Indexed class can correctly handle models that + use the old "indexed_fields" configuration format. + """ + indexed_fields = { + # A search field with predictive search and boosting + 'title': { + 'type': 'string', + 'analyzer': 'edgengram_analyzer', + 'boost': 100, + }, + + # A filter field + 'live': { + 'type': 'boolean', + 'index': 'not_analyzed', + }, + } + +class SearchTestOldConfigList(models.Model, indexed.Indexed): + """ + This tests that the Indexed class can correctly handle models that + use the old "indexed_fields" configuration format using a list. + """ + indexed_fields = ['title', 'content'] diff --git a/wagtail/wagtailsearch/tests/test_indexed_class.py b/wagtail/wagtailsearch/tests/test_indexed_class.py new file mode 100644 index 000000000..a54f135c3 --- /dev/null +++ b/wagtail/wagtailsearch/tests/test_indexed_class.py @@ -0,0 +1,49 @@ +from django.test import TestCase +from wagtail.tests import models +import json + +from wagtail.wagtailsearch import indexed + + +class TestContentTypeNames(TestCase): + def test_base_content_type_name(self): + name = models.SearchTestChild.indexed_get_toplevel_content_type() + self.assertEqual(name, 'tests_searchtest') + + def test_qualified_content_type_name(self): + name = models.SearchTestChild.indexed_get_content_type() + self.assertEqual(name, 'tests_searchtest_tests_searchtestchild') + + +class TestIndexedFieldsBackwardsCompatibility(TestCase): + def test_indexed_fields_backwards_compatibility(self): + # Get search fields + search_fields = models.SearchTestOldConfig.get_search_fields() + + search_fields_dict = dict( + ((field.field_name, type(field)), field) + for field in search_fields + ) + + # Check that the fields were found + self.assertEqual(len(search_fields_dict), 2) + self.assertIn(('title', indexed.SearchField), search_fields_dict.keys()) + self.assertIn(('live', indexed.FilterField), search_fields_dict.keys()) + + # Check that the title field has the correct settings + self.assertTrue(search_fields_dict[('title', indexed.SearchField)].partial_match) + self.assertEqual(search_fields_dict[('title', indexed.SearchField)].boost, 100) + + def test_indexed_fields_backwards_compatibility_list(self): + # Get search fields + search_fields = models.SearchTestOldConfigList.get_search_fields() + + search_fields_dict = dict( + ((field.field_name, type(field)), field) + for field in search_fields + ) + + # Check that the fields were found + self.assertEqual(len(search_fields_dict), 2) + self.assertIn(('title', indexed.SearchField), search_fields_dict.keys()) + self.assertIn(('content', indexed.SearchField), search_fields_dict.keys()) From cd14be23c3361d334f7a9367b1cad3ab45880054 Mon Sep 17 00:00:00 2001 From: Tom Talbot Date: Thu, 3 Jul 2014 12:22:44 +0100 Subject: [PATCH 036/154] Tweak FieldPanel tests --- wagtail/wagtailadmin/tests/test_edit_handlers.py | 6 +++--- wagtail/wagtailsnippets/tests.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/wagtail/wagtailadmin/tests/test_edit_handlers.py b/wagtail/wagtailadmin/tests/test_edit_handlers.py index fa9f3f650..d5af430eb 100644 --- a/wagtail/wagtailadmin/tests/test_edit_handlers.py +++ b/wagtail/wagtailadmin/tests/test_edit_handlers.py @@ -186,15 +186,15 @@ class TestFieldPanel(TestCase): self.assertEqual(self.field_panel.field_type(), 'fake_class') def test_widget_overrides(self): - result = self.field_panel.widget_overrides() + result = FieldPanel.widget_overrides() self.assertEqual(result, {}) def test_required_formsets(self): - result = self.field_panel.required_formsets() + result = FieldPanel.required_formsets() self.assertEqual(result, []) def test_get_form_class(self): - result = self.field_panel.get_form_class(Page) + result = FieldPanel.get_form_class(Page) self.assertTrue(issubclass(result, WagtailAdminModelForm)) def test_render_js(self): diff --git a/wagtail/wagtailsnippets/tests.py b/wagtail/wagtailsnippets/tests.py index 29a0ae284..61bf9baaf 100644 --- a/wagtail/wagtailsnippets/tests.py +++ b/wagtail/wagtailsnippets/tests.py @@ -5,9 +5,12 @@ from wagtail.tests.utils import WagtailTestUtils from wagtail.tests.models import Advert, AlphaSnippet, ZuluSnippet from wagtail.wagtailsnippets.models import register_snippet, SNIPPET_MODELS -from wagtail.wagtailsnippets.views.snippets import get_content_type_from_url_params, get_snippet_edit_handler +from wagtail.wagtailsnippets.views.snippets import ( + get_snippet_edit_handler +) from wagtail.wagtailsnippets.edit_handlers import SnippetChooserPanel + class TestSnippetIndexView(TestCase, WagtailTestUtils): def setUp(self): self.login() From 63e5ca34ce9ad6b9bd1229a207767a51a76328e9 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 3 Jul 2014 12:31:18 +0100 Subject: [PATCH 037/154] Make sure field filters are sorted before checking them --- .../tests/test_elasticsearch_backend.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py b/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py index 88aa87f8b..0c7906b39 100644 --- a/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py +++ b/wagtail/wagtailsearch/tests/test_elasticsearch_backend.py @@ -113,15 +113,26 @@ class TestElasticSearchQuery(TestCase): # Check it expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'and': [{'term': {'live_filter': True}}, {'term': {'title_filter': 'Test'}}]}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} - self.assertDictEqual(query.to_es(), expected_result) + + # Make sure field filters are sorted (as they can be in any order which may cause false positives) + query = query.to_es() + field_filters = query['filtered']['filter']['and'][1]['and'] + field_filters[:] = sorted(field_filters, key=lambda f: list(f['term'].keys())[0]) + + self.assertDictEqual(query, expected_result) def test_or_filter(self): # Create a query query = self.ElasticSearchQuery(models.SearchTest.objects.filter(Q(title="Test") | Q(live=True)), "Hello") + # Make sure field filters are sorted (as they can be in any order which may cause false positives) + query = query.to_es() + field_filters = query['filtered']['filter']['and'][1]['or'] + field_filters[:] = sorted(field_filters, key=lambda f: list(f['term'].keys())[0]) + # Check it - expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'or': [{'term': {'title_filter': 'Test'}}, {'term': {'live_filter': True}}]}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} - self.assertDictEqual(query.to_es(), expected_result) + expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'or': [{'term': {'live_filter': True}}, {'term': {'title_filter': 'Test'}}]}]}, 'query': {'query_string': {'query': 'Hello', 'fields': ['_all', '_partials']}}}} + self.assertDictEqual(query, expected_result) def test_negated_filter(self): # Create a query From 001fcabb46e986f5be8bbe0c4b2e61cd14507d10 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 3 Jul 2014 12:44:53 +0100 Subject: [PATCH 038/154] Fixed python 3.2 incompatible migration --- ...to__del_searchtestchild__del_searchtest.py | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py b/wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py index 560dbaae9..6826ddaa1 100644 --- a/wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py +++ b/wagtail/wagtailsearch/migrations/0003_auto__del_searchtestchild__del_searchtest.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import SchemaMigration @@ -9,76 +11,76 @@ class Migration(SchemaMigration): def forwards(self, orm): # Deleting model 'SearchTestChild' - db.delete_table(u'wagtailsearch_searchtestchild') + db.delete_table('wagtailsearch_searchtestchild') # Deleting model 'SearchTest' - db.delete_table(u'wagtailsearch_searchtest') + db.delete_table('wagtailsearch_searchtest') def backwards(self, orm): # Adding model 'SearchTestChild' - db.create_table(u'wagtailsearch_searchtestchild', ( + db.create_table('wagtailsearch_searchtestchild', ( ('extra_content', self.gf('django.db.models.fields.TextField')()), - (u'searchtest_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['wagtailsearch.SearchTest'], unique=True, primary_key=True)), + ('searchtest_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['wagtailsearch.SearchTest'], unique=True, primary_key=True)), )) - db.send_create_signal(u'wagtailsearch', ['SearchTestChild']) + db.send_create_signal('wagtailsearch', ['SearchTestChild']) # Adding model 'SearchTest' - db.create_table(u'wagtailsearch_searchtest', ( + db.create_table('wagtailsearch_searchtest', ( ('content', self.gf('django.db.models.fields.TextField')()), ('live', self.gf('django.db.models.fields.BooleanField')(default=False)), - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('title', self.gf('django.db.models.fields.CharField')(max_length=255)), )) - db.send_create_signal(u'wagtailsearch', ['SearchTest']) + db.send_create_signal('wagtailsearch', ['SearchTest']) models = { - u'auth.group': { + 'auth.group': { 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) }, - u'auth.user': { + 'auth.user': { 'Meta': {'object_name': 'User'}, 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) }, - u'contenttypes.contenttype': { + 'contenttypes.contenttype': { 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - u'wagtailcore.page': { + 'wagtailcore.page': { 'Meta': {'object_name': 'Page'}, - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"orm['contenttypes.ContentType']"}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['contenttypes.ContentType']"}), 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': u"orm['auth.User']"}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': "orm['auth.User']"}), 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), @@ -87,25 +89,25 @@ class Migration(SchemaMigration): 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) }, - u'wagtailsearch.editorspick': { + 'wagtailsearch.editorspick': { 'Meta': {'ordering': "('sort_order',)", 'object_name': 'EditorsPick'}, 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'page': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['wagtailcore.Page']"}), - 'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'editors_picks'", 'to': u"orm['wagtailsearch.Query']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['wagtailcore.Page']"}), + 'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'editors_picks'", 'to': "orm['wagtailsearch.Query']"}), 'sort_order': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}) }, - u'wagtailsearch.query': { + 'wagtailsearch.query': { 'Meta': {'object_name': 'Query'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'query_string': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}) }, - u'wagtailsearch.querydailyhits': { + 'wagtailsearch.querydailyhits': { 'Meta': {'unique_together': "(('query', 'date'),)", 'object_name': 'QueryDailyHits'}, 'date': ('django.db.models.fields.DateField', [], {}), 'hits': ('django.db.models.fields.IntegerField', [], {'default': '0'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'daily_hits'", 'to': u"orm['wagtailsearch.Query']"}) + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'daily_hits'", 'to': "orm['wagtailsearch.Query']"}) } } From 08ced063ff09d102922deaf279275e4c0fb5ff4d Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 18:56:08 +0100 Subject: [PATCH 039/154] Moved search method into base search backend --- wagtail/wagtailcore/models.py | 2 +- wagtail/wagtailsearch/backends/base.py | 38 ++++++++++++++++++- wagtail/wagtailsearch/backends/db.py | 34 +++++------------ .../wagtailsearch/backends/elasticsearch.py | 34 +---------------- 4 files changed, 48 insertions(+), 60 deletions(-) diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index 4964966a3..b6a664548 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -514,7 +514,7 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, indexed.Index # Search s = get_search_backend() - return s.search(query_string, model=cls, fields=fields, filters=filters, prefetch_related=prefetch_related) + return s.search(query_string, cls, fields=fields, filters=filters, prefetch_related=prefetch_related) @classmethod def clean_subpage_types(cls): diff --git a/wagtail/wagtailsearch/backends/base.py b/wagtail/wagtailsearch/backends/base.py index 393ecae1b..82e2e8d56 100644 --- a/wagtail/wagtailsearch/backends/base.py +++ b/wagtail/wagtailsearch/backends/base.py @@ -1,6 +1,9 @@ from django.db import models +from django.db.models.query import QuerySet +from django.core.exceptions import ImproperlyConfigured from wagtail.wagtailsearch.indexed import Indexed +from wagtail.wagtailsearch.utils import normalise_query_string class BaseSearch(object): @@ -32,5 +35,38 @@ class BaseSearch(object): def delete(self, obj): return NotImplemented - def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]): + def _search(self, queryset, query_string, fields=None): return NotImplemented + + def search(self, query_string, model_or_queryset, fields=None, filters=None, prefetch_related=None): + # Find model/queryset + if isinstance(model_or_queryset, QuerySet): + model = model_or_queryset.model + queryset = model_or_queryset + else: + model = model_or_queryset + queryset = model_or_queryset.objects.all() + + # Model must be a descendant of Indexed and be a django model + if not issubclass(model, Indexed) or not issubclass(model, models.Model): + return [] + + # Normalise query string + if query_string is not None: + query_string = normalise_query_string(query_string) + + # Check that theres still a query string after the clean up + if query_string == "": + return [] + + # Apply filters to queryset + if filters: + queryset = queryset.filter(**filters) + + # Prefetch related + if prefetch_related: + for prefetch in prefetch_related: + queryset = queryset.prefetch_related(prefetch) + + # Search + return self._search(queryset, query_string, fields=fields) diff --git a/wagtail/wagtailsearch/backends/db.py b/wagtail/wagtailsearch/backends/db.py index 56536e343..a94fe3c58 100644 --- a/wagtail/wagtailsearch/backends/db.py +++ b/wagtail/wagtailsearch/backends/db.py @@ -2,7 +2,6 @@ from django.db import models from wagtail.wagtailsearch.backends.base import BaseSearch from wagtail.wagtailsearch.indexed import Indexed -from wagtail.wagtailsearch.utils import normalise_query_string class DBSearch(BaseSearch): @@ -27,26 +26,16 @@ class DBSearch(BaseSearch): def delete(self, obj): pass # Not needed - def search(self, query_string, model, fields=None, filters=None, prefetch_related=None): - # Get fields - if fields is None: - fields = [field.field_name for field in model.get_searchable_search_fields()] - - # Start with all objects - query = model.objects.all() - - # Apply filters - if filters: - query = query.filter(**filters) - + def _search(self, queryset, query_string, fields=None): if query_string is not None: - # Normalise query string - query_string = normalise_query_string(query_string) + # Get fields + if fields is None: + fields = [field.field_name for field in queryset.model.get_searchable_search_fields()] # Get terms terms = query_string.split() if not terms: - return model.objects.none() + return queryset.model.objects.none() # Filter by terms for term in terms: @@ -54,21 +43,16 @@ class DBSearch(BaseSearch): for field_name in fields: # Check if the field exists (this will filter out indexed callables) try: - model._meta.get_field_by_name(field_name) + queryset.model._meta.get_field_by_name(field_name) except: continue # Filter on this field term_query |= models.Q(**{'%s__icontains' % field_name: term}) - query = query.filter(term_query) + queryset = queryset.filter(term_query) # Distinct - query = query.distinct() + queryset = queryset.distinct() - # Prefetch related - if prefetch_related: - for prefetch in prefetch_related: - query = query.prefetch_related(prefetch) - - return query + return queryset diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index ea0413eed..8f17ba6ce 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -3,14 +3,12 @@ from __future__ import absolute_import import json from django.db import models -from django.db.models.query import QuerySet from elasticsearch import Elasticsearch, NotFoundError, RequestError from elasticsearch.helpers import bulk from wagtail.wagtailsearch.backends.base import BaseSearch from wagtail.wagtailsearch.indexed import Indexed, SearchField, FilterField -from wagtail.wagtailsearch.utils import normalise_query_string class ElasticSearchMapping(object): @@ -576,35 +574,5 @@ class ElasticSearch(BaseSearch): except NotFoundError: pass # Document doesn't exist, ignore this exception - def search(self, query_string, model_or_queryset, fields=None, filters=None, prefetch_related=None): - # Find model/queryset - if isinstance(model_or_queryset, QuerySet): - model = model_or_queryset.model - queryset = model_or_queryset - else: - model = model_or_queryset - queryset = model_or_queryset.objects.all() - - # Model must be a descendant of Indexed and be a django model - if not issubclass(model, Indexed) or not issubclass(model, models.Model): - return [] - - # Normalise query string - if query_string is not None: - query_string = normalise_query_string(query_string) - - # Check that theres still a query string after the clean up - if query_string == "": - return [] - - # Apply filters to queryset - if filters: - queryset = queryset.filter(**filters) - - # Prefetch related - if prefetch_related: - for prefetch in prefetch_related: - queryset = queryset.prefetch_related(prefetch) - - # Return search results + def _search(self, queryset, query_string, fields=None): return ElasticSearchResults(self, ElasticSearchQuery(queryset, query_string, fields=fields)) From e35e20dd10e2be669addbde992b24b41d78d9f5a Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 19:14:46 +0100 Subject: [PATCH 040/154] Added search method to PageQuerySet --- wagtail/wagtailcore/models.py | 3 +++ wagtail/wagtailcore/query.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index b6a664548..daee31fb3 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -228,6 +228,9 @@ class PageManager(models.Manager): def not_type(self, model): return self.get_queryset().not_type(model) + def search(self, query_string, fields=None, backend='default'): + return self.get_queryset().search(query_string, fields=fields, backend=backend) + class PageBase(models.base.ModelBase): """Metaclass for Page""" diff --git a/wagtail/wagtailcore/query.py b/wagtail/wagtailcore/query.py index 57e8ffff3..422e46f47 100644 --- a/wagtail/wagtailcore/query.py +++ b/wagtail/wagtailcore/query.py @@ -2,6 +2,8 @@ from django.db.models import Q from django.contrib.contenttypes.models import ContentType from treebeard.mp_tree import MP_NodeQuerySet +from wagtail.wagtailsearch.backends import get_search_backend + class PageQuerySet(MP_NodeQuerySet): """ @@ -107,3 +109,7 @@ class PageQuerySet(MP_NodeQuerySet): def not_type(self, model): return self.exclude(self.type_q(model)) + + def search(self, query_string, fields=None, backend='default'): + search_backend = get_search_backend(backend) + return search_backend.search(query_string, self, fields=None) From b78b6486829f748daf42d6240426abb44d8329a5 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Sun, 22 Jun 2014 21:07:09 +0100 Subject: [PATCH 041/154] Implemented __in lookup in ElasticSearch backend Also added an error message if a user attempts to use a subquery with the __in lookup. --- wagtail/wagtailsearch/backends/elasticsearch.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/wagtail/wagtailsearch/backends/elasticsearch.py b/wagtail/wagtailsearch/backends/elasticsearch.py index 8f17ba6ce..289b0f4b2 100644 --- a/wagtail/wagtailsearch/backends/elasticsearch.py +++ b/wagtail/wagtailsearch/backends/elasticsearch.py @@ -3,6 +3,7 @@ from __future__ import absolute_import import json from django.db import models +from django.db.models.sql.where import SubqueryConstraint from elasticsearch import Elasticsearch, NotFoundError, RequestError from elasticsearch.helpers import bulk @@ -201,7 +202,16 @@ class ElasticSearchQuery(object): } } - raise FilterError('Could not apply filter on ElasticSearch results "' + field_name + '__' + lookup + ' = ' + unicode(value) + '". Lookup "' + lookup + '"" not recognosed.') + if lookup == 'in': + return { + 'terms': { + field_index_name: value, + } + } + + raise FilterError('Could not apply filter on ElasticSearch results: "' + field_name + '__' + lookup + ' = ' + unicode(value) + '". Lookup "' + lookup + '"" not recognosed.') + elif isinstance(where_node, SubqueryConstraint): + raise FilterError('Could not apply filter on ElasticSearch results: Subqueries are not allowed.') # Get child filters connector = where_node.connector From 9e1bd2d601ab540eb0d2d383ddeec369ef7c94c1 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Tue, 24 Jun 2014 14:27:40 +0100 Subject: [PATCH 042/154] Added get_indexed_objects method to Indexed class This gives developers control over the QuerySet used when the model is added to the indexed --- wagtail/wagtailsearch/indexed.py | 4 ++++ wagtail/wagtailsearch/management/commands/update_index.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py index a2cb54d14..2e506d690 100644 --- a/wagtail/wagtailsearch/indexed.py +++ b/wagtail/wagtailsearch/indexed.py @@ -118,6 +118,10 @@ class Indexed(object): def get_filterable_search_fields(cls): return filter(lambda field: isinstance(field, FilterField), cls.get_search_fields()) + @classmethod + def get_indexed_objects(cls): + return cls.objects.all() + indexed_fields = () diff --git a/wagtail/wagtailsearch/management/commands/update_index.py b/wagtail/wagtailsearch/management/commands/update_index.py index 2c1593635..c52b75639 100644 --- a/wagtail/wagtailsearch/management/commands/update_index.py +++ b/wagtail/wagtailsearch/management/commands/update_index.py @@ -24,7 +24,7 @@ class Command(BaseCommand): toplevel_content_type = model.indexed_get_toplevel_content_type() # Loop through objects - for obj in model.objects.all(): + for obj in model.get_indexed_objects(): # Get key for this object key = toplevel_content_type + ':' + str(obj.pk) From b9d3e44ebf1b146987338c137c9bb867a81f52fd Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Tue, 24 Jun 2014 14:28:40 +0100 Subject: [PATCH 043/154] Use get_indexed_objects to speed up indexing of Images/Documents Previously, this created a query for every single image and document to get the tags. This was very slow on RCA which has over 15000 images. This commit fixes this by adding a prefetch_related to the QuerySet used for indexing. --- wagtail/wagtailadmin/taggable.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wagtail/wagtailadmin/taggable.py b/wagtail/wagtailadmin/taggable.py index 91c36ca7f..3b7de479c 100644 --- a/wagtail/wagtailadmin/taggable.py +++ b/wagtail/wagtailadmin/taggable.py @@ -21,7 +21,11 @@ class TagSearchable(indexed.Indexed): @property def get_tags(self): - return ' '.join([tag.name for tag in self.tags.all()]) + return ' '.join([tag.name for tag in self.prefetched_tags()]) + + @classmethod + def get_indexed_objects(cls): + return super(TagSearchable, cls).get_indexed_objects().prefetch_related('tagged_items__tag') @classmethod def search(cls, q, results_per_page=None, page=1, prefetch_tags=False, filters={}): From c9385ca1ce241ff15f88faa262800ae561098d58 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 3 Jul 2014 13:10:03 +0100 Subject: [PATCH 044/154] frontend cache purge docs change --- docs/frontend_cache_purging.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/frontend_cache_purging.rst b/docs/frontend_cache_purging.rst index e8564f6c1..34a6fda50 100644 --- a/docs/frontend_cache_purging.rst +++ b/docs/frontend_cache_purging.rst @@ -107,9 +107,11 @@ Purging individual URLs ``wagtail.contrib.wagtailfrontendcache.utils`` provides another utils function called ``purge_url_from_cache``. As the name suggests, this purges an individual URL from the cache. +For example, this could be useful for purging a single page of blogs: + .. code-block:: python from wagtail.contrib.wagtailfrontendcache.utils import purge_url_from_cache - # Purge the homepage - purge_url_from_cache(homepage.full_url) + # Purge the first page of the blog index + purge_url_from_cache(blog_index.url + '?page=1') From 69077927f9a7132a5e7f1a8e19b7423648abb21b Mon Sep 17 00:00:00 2001 From: Tom Talbot Date: Thu, 3 Jul 2014 13:56:43 +0100 Subject: [PATCH 045/154] Fix FieldPanel test failures --- wagtail/wagtailadmin/tests/test_edit_handlers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wagtail/wagtailadmin/tests/test_edit_handlers.py b/wagtail/wagtailadmin/tests/test_edit_handlers.py index d5af430eb..30fda39a5 100644 --- a/wagtail/wagtailadmin/tests/test_edit_handlers.py +++ b/wagtail/wagtailadmin/tests/test_edit_handlers.py @@ -186,15 +186,15 @@ class TestFieldPanel(TestCase): self.assertEqual(self.field_panel.field_type(), 'fake_class') def test_widget_overrides(self): - result = FieldPanel.widget_overrides() + result = FieldPanel('barbecue', 'snowman').widget_overrides() self.assertEqual(result, {}) def test_required_formsets(self): - result = FieldPanel.required_formsets() + result = FieldPanel('barbecue', 'snowman').required_formsets() self.assertEqual(result, []) def test_get_form_class(self): - result = FieldPanel.get_form_class(Page) + result = FieldPanel('barbecue', 'snowman').get_form_class(Page) self.assertTrue(issubclass(result, WagtailAdminModelForm)) def test_render_js(self): From c0cb1b62806f3b81d2410d1d8382b32922c2619f Mon Sep 17 00:00:00 2001 From: Tom Talbot Date: Thu, 3 Jul 2014 14:11:29 +0100 Subject: [PATCH 046/154] Remove mock expected calls from edit handler tests 'Expected calls' works differently in Python 2 and 3 --- wagtail/wagtailadmin/tests/test_edit_handlers.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/wagtail/wagtailadmin/tests/test_edit_handlers.py b/wagtail/wagtailadmin/tests/test_edit_handlers.py index 30fda39a5..48e24aff2 100644 --- a/wagtail/wagtailadmin/tests/test_edit_handlers.py +++ b/wagtail/wagtailadmin/tests/test_edit_handlers.py @@ -357,8 +357,6 @@ class TestInlinePanel(TestCase): form=self.fake_field) result = inline_panel.get_panel_definitions() self.assertEqual(result[0].name, 'mock panel') - expected_calls = '[call(instance=fake instance, form=fake form),\n call(instance=fake instance, form=fake form)]' - self.assertEqual(str(self.mock_panel.mock_calls), expected_calls) def test_get_panel_definitions(self): """ @@ -374,8 +372,6 @@ class TestInlinePanel(TestCase): form=self.fake_field) result = inline_panel.get_panel_definitions() self.assertEqual(result[0].name, 'other mock panel') - expected_calls = '[call(instance=fake instance, form=fake form),\n call(instance=fake instance, form=fake form)]' - self.assertEqual(str(other_mock_panel.mock_calls), expected_calls) def test_required_formsets(self): inline_panel = InlinePanel(self.mock_model, 'formset')( From 532bb6241e7cbced94fb044306e3eba7a447be82 Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Thu, 3 Jul 2014 14:26:05 +0100 Subject: [PATCH 047/154] Added some more FilterFields to Page --- wagtail/wagtailcore/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index daee31fb3..75021d584 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -285,8 +285,12 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, indexed.Index search_fields = ( indexed.SearchField('title', partial_match=True, boost=100), + indexed.FilterField('id'), indexed.FilterField('live'), + indexed.FilterField('owner'), + indexed.FilterField('content_type'), indexed.FilterField('path'), + indexed.FilterField('depth'), ) def __init__(self, *args, **kwargs): From dd52ccab2aa41f8410da18ee0b26d99e3adb87a3 Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Thu, 12 Jun 2014 13:03:46 +0100 Subject: [PATCH 048/154] Remove X-Requested-With header hacks on the incoming requests to preview_on_edit and preview_on_create - no longer necessary, because we're not passing that request to the preview logic Conflicts: wagtail/wagtailadmin/views/pages.py --- wagtail/wagtailadmin/views/pages.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py index fd0c97b02..7a2ec4f06 100644 --- a/wagtail/wagtailadmin/views/pages.py +++ b/wagtail/wagtailadmin/views/pages.py @@ -409,13 +409,6 @@ def preview_on_edit(request, page_id): if form.is_valid(): form.save(commit=False) - # This view will generally be invoked as an AJAX request; as such, in the case of - # an error Django will return a plaintext response. This isn't what we want, since - # we will be writing the response back to an HTML page regardless of success or - # failure - as such, we strip out the X-Requested-With header to get Django to return - # an HTML error response - request.META.pop('HTTP_X_REQUESTED_WITH', None) - try: display_mode = request.GET['mode'] except KeyError: @@ -465,13 +458,6 @@ def preview_on_create(request, content_type_app_name, content_type_model_name, p page.depth = parent_page.depth + 1 page.path = Page._get_children_path_interval(parent_page.path)[1] - # This view will generally be invoked as an AJAX request; as such, in the case of - # an error Django will return a plaintext response. This isn't what we want, since - # we will be writing the response back to an HTML page regardless of success or - # failure - as such, we strip out the X-Requested-With header to get Django to return - # an HTML error response - request.META.pop('HTTP_X_REQUESTED_WITH', None) - try: display_mode = request.GET['mode'] except KeyError: From 78481dc84665a9b27645c1139cb90a02ff6128e1 Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Thu, 12 Jun 2014 16:48:44 +0100 Subject: [PATCH 049/154] syntactic sugar for get_page_modes: change it to a property called preview_modes and add a default_preview_mode helper Conflicts: wagtail/wagtailadmin/views/pages.py --- docs/building_your_site/djangodevelopers.rst | 2 +- docs/model_recipes.rst | 6 ++--- wagtail/wagtailadmin/views/pages.py | 23 +++++++------------- wagtail/wagtailcore/models.py | 15 ++++++++++--- wagtail/wagtailforms/models.py | 9 ++++---- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/docs/building_your_site/djangodevelopers.rst b/docs/building_your_site/djangodevelopers.rst index aeabb264f..e72573089 100644 --- a/docs/building_your_site/djangodevelopers.rst +++ b/docs/building_your_site/djangodevelopers.rst @@ -201,6 +201,7 @@ Properties: * status_string * subpage_types * indexed_fields +* preview_modes Methods: @@ -213,7 +214,6 @@ Methods: * get_descendants * get_siblings * search -* get_page_modes * show_as_mode diff --git a/docs/model_recipes.rst b/docs/model_recipes.rst index 6a4abed47..ae011126a 100644 --- a/docs/model_recipes.rst +++ b/docs/model_recipes.rst @@ -190,10 +190,10 @@ Load Alternate Templates by Overriding get_template() -Page Modes ----------- +Preview Modes +------------- -get_page_modes +preview_modes show_as_mode diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py index 7a2ec4f06..e5a0c885c 100644 --- a/wagtail/wagtailadmin/views/pages.py +++ b/wagtail/wagtailadmin/views/pages.py @@ -230,7 +230,7 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ 'page_class': page_class, 'parent_page': parent_page, 'edit_handler': edit_handler, - 'display_modes': page.get_page_modes(), + 'display_modes': page.preview_modes, 'form': form, # Used in unit tests }) @@ -361,7 +361,7 @@ def edit(request, page_id): 'page': page, 'edit_handler': edit_handler, 'errors_debug': errors_debug, - 'display_modes': page.get_page_modes(), + 'display_modes': page.preview_modes, 'form': form, # Used in unit tests }) @@ -409,12 +409,8 @@ def preview_on_edit(request, page_id): if form.is_valid(): form.save(commit=False) - try: - display_mode = request.GET['mode'] - except KeyError: - display_mode = page.get_page_modes()[0][0] - - response = page.show_as_mode(display_mode) + preview_mode = request.GET.get('mode', page.default_preview_mode) + response = page.show_as_mode(preview_mode) response['X-Wagtail-Preview'] = 'ok' return response @@ -425,7 +421,7 @@ def preview_on_edit(request, page_id): response = render(request, 'wagtailadmin/pages/edit.html', { 'page': page, 'edit_handler': edit_handler, - 'display_modes': page.get_page_modes(), + 'display_modes': page.preview_modes, }) response['X-Wagtail-Preview'] = 'error' return response @@ -458,11 +454,8 @@ def preview_on_create(request, content_type_app_name, content_type_model_name, p page.depth = parent_page.depth + 1 page.path = Page._get_children_path_interval(parent_page.path)[1] - try: - display_mode = request.GET['mode'] - except KeyError: - display_mode = page.get_page_modes()[0][0] - response = page.show_as_mode(display_mode) + preview_mode = request.GET.get('mode', page.default_preview_mode) + response = page.show_as_mode(preview_mode) response['X-Wagtail-Preview'] = 'ok' return response @@ -476,7 +469,7 @@ def preview_on_create(request, content_type_app_name, content_type_model_name, p 'page_class': page_class, 'parent_page': parent_page, 'edit_handler': edit_handler, - 'display_modes': page.get_page_modes(), + 'display_modes': page.preview_modes, }) response['X-Wagtail-Preview'] = 'error' return response diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index b980d7f54..42e132daa 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -706,19 +706,28 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)): "request middleware returned a response") return request - def get_page_modes(self): + @property + def preview_modes(self): """ - Return a list of (internal_name, display_name) tuples for the modes in which + A list of (internal_name, display_name) tuples for the modes in which this page can be displayed for preview/moderation purposes. Ordinarily a page will only have one display mode, but subclasses of Page can override this - for example, a page containing a form might have a default view of the form, and a post-submission 'thankyou' page """ + return self.get_page_modes() + + def get_page_modes(self): + # Deprecated accessor for the preview_modes property return [('', 'Default')] + @property + def default_preview_mode(self): + return self.preview_modes[0][0] + def show_as_mode(self, mode_name): """ - Given an internal name from the get_page_modes() list, return an HTTP response + Given an internal name from the preview_modes list, return an HTTP response indicative of the page being viewed in that mode. By default this passes a dummy request into the serve() mechanism, ensuring that it matches the behaviour on the front-end; subclasses that define additional page modes will need to diff --git a/wagtail/wagtailforms/models.py b/wagtail/wagtailforms/models.py index 41414d391..6bde684ac 100644 --- a/wagtail/wagtailforms/models.py +++ b/wagtail/wagtailforms/models.py @@ -170,11 +170,10 @@ class AbstractForm(Page): 'form': form, }) - def get_page_modes(self): - return [ - ('form', 'Form'), - ('landing', 'Landing page'), - ] + preview_modes = [ + ('form', 'Form'), + ('landing', 'Landing page'), + ] def show_as_mode(self, mode): if mode == 'landing': From 0a38e0b8d3122db090475ac887ff6d00f9d1b2ff Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Thu, 12 Jun 2014 16:57:09 +0100 Subject: [PATCH 050/154] rename display_modes to preview_modes for consistency Conflicts: wagtail/wagtailadmin/views/pages.py --- .../wagtailadmin/templates/wagtailadmin/pages/create.html | 4 ++-- .../wagtailadmin/templates/wagtailadmin/pages/edit.html | 4 ++-- wagtail/wagtailadmin/views/pages.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html index b87af8fae..f21b93a27 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html @@ -39,12 +39,12 @@
  • {% trans 'Preview' as preview_label %} - {% if display_modes|length > 1 %} + {% if preview_modes|length > 1 %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index 08e1b612b..8670f0315 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -811,6 +811,9 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)): def get_prev_siblings(self, inclusive=False): return self.get_siblings(inclusive).filter(path__lte=self.path).order_by('-path') + def get_view_restrictions(self): + return PageViewRestriction.objects.filter(page__in=self.get_ancestors(inclusive=True)) + password_required_template = getattr(settings, 'PASSWORD_REQUIRED_TEMPLATE', 'wagtailcore/password_required.html') def serve_password_required_response(self, request, form, action_url): """ diff --git a/wagtail/wagtailcore/wagtail_hooks.py b/wagtail/wagtailcore/wagtail_hooks.py index b2ab31248..e92971c99 100644 --- a/wagtail/wagtailcore/wagtail_hooks.py +++ b/wagtail/wagtailcore/wagtail_hooks.py @@ -1,11 +1,10 @@ from django.core.urlresolvers import reverse from wagtail.wagtailcore import hooks -from wagtail.wagtailcore.models import PageViewRestriction from wagtail.wagtailcore.forms import PasswordPageViewRestrictionForm def check_view_restrictions(page, request): - restrictions = PageViewRestriction.objects.filter(page__in=page.get_ancestors(inclusive=True)) + restrictions = page.get_view_restrictions() if restrictions: passed_restrictions = request.session.get('passed_page_view_restrictions', []) From eaee2b9695842f4c38a79874a55eec1e0aeb4065 Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Fri, 6 Jun 2014 12:52:34 +0100 Subject: [PATCH 069/154] Implement (mostly-empty) views for setting view restrictions, accessible from the permission indicator --- .../js/view-permission-indicator.js | 9 +++++ .../ancestor_restriction.html | 8 +++++ .../set_view_restrictions.html | 7 ++++ .../wagtailadmin/pages/_editor_js.html | 1 + .../pages/_view_permission_indicator.html | 25 ++++++++++--- .../templates/wagtailadmin/pages/edit.html | 6 ++-- .../templates/wagtailadmin/pages/index.html | 4 +++ .../templates/wagtailadmin/pages/list.html | 2 +- wagtail/wagtailadmin/urls.py | 4 ++- .../views/page_view_restrictions.py | 35 +++++++++++++++++++ wagtail/wagtailcore/models.py | 3 ++ 11 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 wagtail/wagtailadmin/static/wagtailadmin/js/view-permission-indicator.js create mode 100644 wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/ancestor_restriction.html create mode 100644 wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/set_view_restrictions.html create mode 100644 wagtail/wagtailadmin/views/page_view_restrictions.py diff --git a/wagtail/wagtailadmin/static/wagtailadmin/js/view-permission-indicator.js b/wagtail/wagtailadmin/static/wagtailadmin/js/view-permission-indicator.js new file mode 100644 index 000000000..52b7442a7 --- /dev/null +++ b/wagtail/wagtailadmin/static/wagtailadmin/js/view-permission-indicator.js @@ -0,0 +1,9 @@ +$(function() { + /* Interface to set view permissions from the explorer / editor */ + $('a.action-set-view-permissions').click(function() { + ModalWorkflow({ + 'url': this.href + }); + return false; + }); +}); diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/ancestor_restriction.html b/wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/ancestor_restriction.html new file mode 100644 index 000000000..d8b652f32 --- /dev/null +++ b/wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/ancestor_restriction.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% trans "Access restricted" as title_str %} +{% include "wagtailadmin/shared/header.html" with title=title_str %} + +
    + {% trans "Access to this page is restricted because it is within the section:" %} + {{ page_with_restriction.title }} +
    diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/set_view_restrictions.html b/wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/set_view_restrictions.html new file mode 100644 index 000000000..82e928c68 --- /dev/null +++ b/wagtail/wagtailadmin/templates/wagtailadmin/page_view_restrictions/set_view_restrictions.html @@ -0,0 +1,7 @@ +{% load i18n %} +{% trans "Set access restrictions" as title_str %} +{% include "wagtailadmin/shared/header.html" with title=title_str %} + +
    +

    form for setting access restrictions goes here

    +
    diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html index 17bcbd8bf..f494f75ec 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html @@ -21,6 +21,7 @@ + {% hook_output 'insert_editor_js' %} {% endcompress %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_view_permission_indicator.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_view_permission_indicator.html index 7f9c7d82b..030703aab 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_view_permission_indicator.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_view_permission_indicator.html @@ -1,6 +1,21 @@ -{% load i18n %} -{% if page.get_view_restrictions %} -
    {% trans 'Private' %}
    -{% else %} -
    {% trans 'Public' %}
    +{% load i18n wagtailadmin_tags %} + +{% if not page_perms %} + {% page_permissions page as page_perms %} {% endif %} + +{% with page.get_view_restrictions as has_view_restrictions %} + {% if has_view_restrictions %} + {% trans 'Private' as label %} + {% else %} + {% trans 'Public' as label %} + {% endif %} + + {% if page_perms.can_set_view_restrictions %} + + {% else %} +
    {{ label }}
    + {% endif %} +{% endwith %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html index e56ad170c..a1b108933 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html @@ -6,6 +6,7 @@ {% block bodyclass %}menu-explorer page-editor{% endblock %} {% block content %} + {% page_permissions page as page_perms %}
    {% include "wagtailadmin/shared/breadcrumb.html" with page=page %} @@ -17,7 +18,7 @@ {% trans "Status:" %} {% if page.live %}{{ page.status_string }}{% else %}{{ page.status_string }}{% endif %} - {% include "wagtailadmin/pages/_view_permission_indicator.html" with page=page only %} + {% include "wagtailadmin/pages/_view_permission_indicator.html" with page=page page_perms=page_perms only %}
    @@ -25,8 +26,7 @@
    {% csrf_token %} {{ edit_handler.render_form_content }} - - {% page_permissions page as page_perms %} +