diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py
index df5ad0eeb..7c8c05750 100644
--- a/wagtail/wagtailcore/models.py
+++ b/wagtail/wagtailcore/models.py
@@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import Group
from treebeard.mp_tree import MP_Node
from cluster.models import ClusterableModel
-from verdantsearch import Indexed, Searcher
+from wagtail.wagtailsearch import Indexed, Searcher
from wagtail.wagtailcore.util import camelcase_to_underscore
diff --git a/wagtail/wagtailsearch/__init__.py b/wagtail/wagtailsearch/__init__.py
new file mode 100644
index 000000000..f436ec1fe
--- /dev/null
+++ b/wagtail/wagtailsearch/__init__.py
@@ -0,0 +1,4 @@
+from indexed import Indexed
+from search import Search
+from searcher import Searcher
+from signal_handlers import register_signal_handlers
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/forms.py b/wagtail/wagtailsearch/forms.py
new file mode 100644
index 000000000..c80f67eb2
--- /dev/null
+++ b/wagtail/wagtailsearch/forms.py
@@ -0,0 +1,36 @@
+from django import forms
+from django.forms.models import inlineformset_factory
+import models
+
+
+class QueryForm(forms.Form):
+ query_string = forms.CharField(label='Search term(s)/phrase', help_text="Enter the full search string to match. An exact match is required for your Editors Picks to be displayed, wildcards are NOT allowed.", required=True)
+
+
+class EditorsPickForm(forms.ModelForm):
+ sort_order = forms.IntegerField(required=False)
+
+ def __init__(self, *args, **kwargs):
+ super(EditorsPickForm, self).__init__(*args, **kwargs)
+ self.fields['page'].widget = forms.HiddenInput()
+
+ class Meta:
+ model = models.EditorsPick
+
+ widgets = {
+ 'description': forms.Textarea(attrs=dict(rows=3)),
+ }
+
+
+EditorsPickFormSetBase = inlineformset_factory(models.Query, models.EditorsPick, form=EditorsPickForm, can_order=True, can_delete=True, extra=0)
+
+class EditorsPickFormSet(EditorsPickFormSetBase):
+ def add_fields(self, form, *args, **kwargs):
+ super(EditorsPickFormSet, self).add_fields(form, *args, **kwargs)
+
+ # Hide delete and order fields
+ form.fields['DELETE'].widget = forms.HiddenInput()
+ form.fields['ORDER'].widget = forms.HiddenInput()
+
+ # Remove query field
+ del form.fields['query']
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/indexed.py b/wagtail/wagtailsearch/indexed.py
new file mode 100644
index 000000000..ad3acf3fc
--- /dev/null
+++ b/wagtail/wagtailsearch/indexed.py
@@ -0,0 +1,78 @@
+from django.db import models
+
+
+class Indexed(object):
+ @classmethod
+ def indexed_get_parent(cls, require_model=True):
+ for base in cls.__bases__:
+ if issubclass(base, Indexed) and (issubclass(base, models.Model) or require_model == False):
+ return base
+
+ @classmethod
+ def indexed_get_content_type(cls):
+ # Work out content type
+ 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
+ else:
+ return content_type
+
+ @classmethod
+ def indexed_get_toplevel_content_type(cls):
+ # Get parent content type
+ parent = cls.indexed_get_parent()
+ if parent:
+ return parent.indexed_get_content_type()
+ else:
+ # At toplevel, return this content type
+ return (cls._meta.app_label + "_" + cls.__name__).lower()
+
+ @classmethod
+ def indexed_get_indexed_fields(cls):
+ # Get indexed fields for this class as dictionary
+ indexed_fields = cls.indexed_fields
+ if isinstance(indexed_fields, tuple):
+ indexed_fields = list(indexed_fields)
+ if isinstance(indexed_fields, basestring):
+ indexed_fields = [indexed_fields]
+ if isinstance(indexed_fields, list):
+ indexed_fields = {field: dict(type="string") for field in indexed_fields}
+ if not isinstance(indexed_fields, dict):
+ raise ValueError()
+
+ # Get indexed fields for parent class
+ parent = cls.indexed_get_parent(require_model=False)
+ if parent:
+ # Add parent fields into this list
+ parent_indexed_fields = parent.indexed_get_indexed_fields()
+ indexed_fields = dict(parent_indexed_fields.items() + indexed_fields.items())
+ 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 = ()
+ indexed = True
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/management/__init__.py b/wagtail/wagtailsearch/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtail/wagtailsearch/management/commands/__init__.py b/wagtail/wagtailsearch/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtail/wagtailsearch/management/commands/search_garbage_collect.py b/wagtail/wagtailsearch/management/commands/search_garbage_collect.py
new file mode 100644
index 000000000..f27f18974
--- /dev/null
+++ b/wagtail/wagtailsearch/management/commands/search_garbage_collect.py
@@ -0,0 +1,15 @@
+from django.core.management.base import NoArgsCommand
+from wagtail.wagtailsearch import models
+
+
+class Command(NoArgsCommand):
+ def handle_noargs(self, **options):
+ # Clean daily hits
+ print "Cleaning daily hits records... ",
+ models.QueryDailyHits.garbage_collect()
+ print "Done"
+
+ # Clean queries
+ print "Cleaning query records... ",
+ models.Query.garbage_collect()
+ print "Done"
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/management/commands/update_index.py b/wagtail/wagtailsearch/management/commands/update_index.py
new file mode 100644
index 000000000..f778c6a18
--- /dev/null
+++ b/wagtail/wagtailsearch/management/commands/update_index.py
@@ -0,0 +1,67 @@
+from django.core.management.base import NoArgsCommand
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import models
+from wagtail.wagtailsearch.indexed import Indexed
+from wagtail.wagtailsearch.search import Search
+
+
+class Command(NoArgsCommand):
+ def handle_noargs(self, **options):
+ # Print info
+ print "Getting object list"
+
+ # Get list of indexed models
+ indexed_models = [model for model in models.get_models() if issubclass(model, Indexed)]
+
+ # Object set
+ object_set = {}
+
+ # Add all objects to object set and detect any duplicates
+ # Duplicates are caused when both a model and a derived model are indexed
+ # Eg, StudentPage inherits from Page and both of these models are indexed
+ # If we were to add all objects from both models into the index, all the StudentPages will have two entries
+ for model in indexed_models:
+ # Get toplevel content type
+ toplevel_content_type = model.indexed_get_toplevel_content_type()
+
+ # 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() == False:
+ continue
+
+ # Get key for this object
+ key = toplevel_content_type + ":" + str(obj.pk)
+
+ # Check if this key already exists
+ if key in object_set:
+ # Conflict, work out who should get this space
+ # The object with the longest content type string gets the space
+ # Eg, "wagtailcore.Page-rca.StudentPage" kicks out "wagtailcore.Page"
+ if len(obj.indexed_get_content_type()) > len(object_set[key].indexed_get_content_type()):
+ # Take the spot
+ object_set[key] = obj
+ else:
+ # Space free, take it
+ object_set[key] = obj
+
+ # Search object
+ s = Search()
+
+ # Reset the index
+ print "Reseting index"
+ s.reset_index()
+
+ # Add types
+ print "Adding types"
+ for model in indexed_models:
+ s.add_type(model)
+
+ # Add objects to index
+ print "Adding objects"
+ s.add_bulk(object_set.values())
+
+ # Refresh index
+ print "Refreshing index"
+ s.refresh_index()
diff --git a/wagtail/wagtailsearch/migrations/0001_initial.py b/wagtail/wagtailsearch/migrations/0001_initial.py
new file mode 100644
index 000000000..1ddbeaa55
--- /dev/null
+++ b/wagtail/wagtailsearch/migrations/0001_initial.py
@@ -0,0 +1,167 @@
+# -*- 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):
+
+ depends_on = (
+ ("wagtailcore", "0002_initial_data"),
+ )
+
+ def forwards(self, orm):
+ # Adding model 'Query'
+ db.create_table(u'wagtailsearch_query', (
+ (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('query_string', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
+ ))
+ db.send_create_signal(u'wagtailsearch', ['Query'])
+
+ # Adding model 'QueryDailyHits'
+ db.create_table(u'wagtailsearch_querydailyhits', (
+ (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('query', self.gf('django.db.models.fields.related.ForeignKey')(related_name='daily_hits', to=orm['wagtailsearch.Query'])),
+ ('date', self.gf('django.db.models.fields.DateField')()),
+ ('hits', self.gf('django.db.models.fields.IntegerField')(default=0)),
+ ))
+ db.send_create_signal(u'wagtailsearch', ['QueryDailyHits'])
+
+ # Adding unique constraint on 'QueryDailyHits', fields ['query', 'date']
+ db.create_unique(u'wagtailsearch_querydailyhits', ['query_id', 'date'])
+
+ # Adding model 'EditorsPick'
+ db.create_table(u'wagtailsearch_editorspick', (
+ (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('query', self.gf('django.db.models.fields.related.ForeignKey')(related_name='editors_picks', to=orm['wagtailsearch.Query'])),
+ ('page', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['wagtailcore.Page'])),
+ ('sort_order', self.gf('django.db.models.fields.IntegerField')(null=True, blank=True)),
+ ('description', self.gf('django.db.models.fields.TextField')(blank=True)),
+ ))
+ db.send_create_signal(u'wagtailsearch', ['EditorsPick'])
+
+ # Adding model 'SearchTest'
+ db.create_table(u'wagtailsearch_searchtest', (
+ (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('title', self.gf('django.db.models.fields.CharField')(max_length=255)),
+ ('content', self.gf('django.db.models.fields.TextField')()),
+ ))
+ db.send_create_signal(u'wagtailsearch', ['SearchTest'])
+
+ # Adding model 'SearchTestChild'
+ db.create_table(u'wagtailsearch_searchtestchild', (
+ (u'searchtest_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['wagtailsearch.SearchTest'], unique=True, primary_key=True)),
+ ('extra_content', self.gf('django.db.models.fields.TextField')()),
+ ))
+ db.send_create_signal(u'wagtailsearch', ['SearchTestChild'])
+
+
+ def backwards(self, orm):
+ # Removing unique constraint on 'QueryDailyHits', fields ['query', 'date']
+ db.delete_unique(u'wagtailsearch_querydailyhits', ['query_id', 'date'])
+
+ # Deleting model 'Query'
+ db.delete_table(u'wagtailsearch_query')
+
+ # Deleting model 'QueryDailyHits'
+ db.delete_table(u'wagtailsearch_querydailyhits')
+
+ # Deleting model 'EditorsPick'
+ db.delete_table(u'wagtailsearch_editorspick')
+
+ # Deleting model 'SearchTest'
+ db.delete_table(u'wagtailsearch_searchtest')
+
+ # Deleting model 'SearchTestChild'
+ db.delete_table(u'wagtailsearch_searchtestchild')
+
+
+ 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']"})
+ },
+ u'wagtailsearch.searchtest': {
+ 'Meta': {'object_name': 'SearchTest'},
+ 'content': ('django.db.models.fields.TextField', [], {}),
+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+ },
+ u'wagtailsearch.searchtestchild': {
+ 'Meta': {'object_name': 'SearchTestChild', '_ormbases': [u'wagtailsearch.SearchTest']},
+ 'extra_content': ('django.db.models.fields.TextField', [], {}),
+ u'searchtest_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': u"orm['wagtailsearch.SearchTest']", 'unique': 'True', 'primary_key': 'True'})
+ }
+ }
+
+ complete_apps = ['wagtailsearch']
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/migrations/__init__.py b/wagtail/wagtailsearch/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtail/wagtailsearch/models.py b/wagtail/wagtailsearch/models.py
new file mode 100644
index 000000000..40c6f4c71
--- /dev/null
+++ b/wagtail/wagtailsearch/models.py
@@ -0,0 +1,101 @@
+from django.db import models
+from django.utils import timezone
+from wagtail.wagtailcore.models import Page
+from indexed import Indexed
+from searcher import Searcher
+import datetime
+import string
+
+
+class Query(models.Model):
+ query_string = models.CharField(max_length=255, unique=True)
+
+ def save(self, *args, **kwargs):
+ # Normalise query string
+ self.query_string = self.normalise_query_string(self.query_string)
+
+ super(Query, self).save(*args, **kwargs)
+
+ def add_hit(self):
+ daily_hits, created = QueryDailyHits.objects.get_or_create(query=self, date=timezone.now().date())
+ daily_hits.hits = models.F('hits') + 1
+ daily_hits.save()
+
+ @property
+ def hits(self):
+ return self.daily_hits.aggregate(models.Sum('hits'))['hits__sum']
+
+ @classmethod
+ def garbage_collect(cls):
+ """
+ Deletes all Query records that have no daily hits or editors picks
+ """
+ cls.objects.filter(daily_hits__isnull=True, editors_picks__isnull=True).delete()
+
+ @classmethod
+ def get(cls, query_string):
+ return cls.objects.get_or_create(query_string=cls.normalise_query_string(query_string))[0]
+
+ @classmethod
+ def get_most_popular(cls, date_since=None):
+ return cls.objects.filter(daily_hits__isnull=False).annotate(_hits=models.Sum('daily_hits__hits')).distinct().order_by('-_hits')
+
+ @staticmethod
+ def normalise_query_string(query_string):
+ # Convert query_string to lowercase
+ query_string = query_string.lower()
+
+ # Strip punctuation characters
+ query_string = ''.join([c for c in query_string if c not in string.punctuation])
+
+ # Remove double spaces
+ ' '.join(query_string.split())
+
+ return query_string
+
+
+class QueryDailyHits(models.Model):
+ query = models.ForeignKey(Query, db_index=True, related_name='daily_hits')
+ date = models.DateField()
+ hits = models.IntegerField(default=0)
+
+ @classmethod
+ def garbage_collect(cls):
+ """
+ Deletes all QueryDailyHits records that are older than 7 days
+ """
+ min_date = timezone.now().date() - datetime.timedelta(days=7)
+
+ cls.objects.filter(date__lt=min_date).delete()
+
+ class Meta:
+ unique_together = (
+ ('query', 'date'),
+ )
+
+
+class EditorsPick(models.Model):
+ query = models.ForeignKey(Query, db_index=True, related_name='editors_picks')
+ page = models.ForeignKey('wagtailcore.Page')
+ sort_order = models.IntegerField(null=True, blank=True, editable=False)
+ description = models.TextField(blank=True)
+
+ class Meta:
+ ordering = ('sort_order', )
+
+
+# Used for tests
+
+class SearchTest(models.Model, Indexed):
+ title = models.CharField(max_length=255)
+ content = models.TextField()
+
+ indexed_fields = ("title", "content")
+
+ title_search = Searcher(["title"])
+
+
+class SearchTestChild(SearchTest):
+ extra_content = models.TextField()
+
+ indexed_fields = "extra_content"
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/search.py b/wagtail/wagtailsearch/search.py
new file mode 100644
index 000000000..25d127967
--- /dev/null
+++ b/wagtail/wagtailsearch/search.py
@@ -0,0 +1,243 @@
+from indexed import Indexed
+from django.db import models
+from django.conf import settings
+from pyelasticsearch.exceptions import ElasticHttpNotFoundError
+from elasticutils import get_es, S
+import string
+
+
+class SearchResults(object):
+ def __init__(self, model, query, prefetch_related=[]):
+ self.model = model
+ self.query = query
+ self.count = query.count()
+ self.prefetch_related = prefetch_related
+
+ def __getitem__(self, key):
+ if isinstance(key, slice):
+ # Get primary keys
+ pk_list_unclean = [result._source["pk"] for result in self.query[key]]
+
+ # Remove duplicate keys (and preserve order)
+ seen_pks = set()
+ pk_list = []
+ for pk in pk_list_unclean:
+ if pk not in seen_pks:
+ seen_pks.add(pk)
+ pk_list.append(pk)
+
+ # Get results
+ results = self.model.objects.filter(pk__in=pk_list)
+
+ # Prefetch related
+ for prefetch in self.prefetch_related:
+ results = results.prefetch_related(prefetch)
+
+ # Put results into a dictionary (using primary key as the key)
+ results_dict = {str(result.pk): result for result in results}
+
+ # Build new list with items in the correct order
+ results_sorted = [results_dict[str(pk)] for pk in pk_list if str(pk) in results_dict]
+
+ # Return the list
+ return results_sorted
+ else:
+ # Return a single item
+ pk = self.query[key]._source["pk"]
+ return self.model.objects.get(pk=pk)
+
+ def __len__(self):
+ return self.count
+
+
+class Search(object):
+ def __init__(self):
+ # Get settings
+ self.es_urls = getattr(settings, "WAGTAILSEARCH_ES_URLS", ["http://localhost:9200"])
+ self.es_index = getattr(settings, "WAGTAILSEARCH_ES_INDEX", "verdant")
+
+ # Get ElasticSearch interface
+ self.es = get_es(urls=self.es_urls)
+ self.s = S().es(urls=self.es_urls).indexes(self.es_index)
+
+ def reset_index(self):
+ # Delete old index
+ try:
+ self.es.delete_index(self.es_index)
+ except ElasticHttpNotFoundError:
+ pass
+
+ # Settings
+ INDEX_SETTINGS = {
+ "settings": {
+ "analysis": {
+ "analyzer": {
+ "ngram_analyzer": {
+ "type": "custom",
+ "tokenizer": "lowercase",
+ "filter": ["ngram"]
+ },
+ "edgengram_analyzer": {
+ "type": "custom",
+ "tokenizer": "lowercase",
+ "filter": ["edgengram"]
+ }
+ },
+ "tokenizer": {
+ "ngram_tokenizer": {
+ "type": "nGram",
+ "min_gram": 3,
+ "max_gram": 15,
+ },
+ "edgengram_tokenizer": {
+ "type": "edgeNGram",
+ "min_gram": 2,
+ "max_gram": 15,
+ "side": "front"
+ }
+ },
+ "filter": {
+ "ngram": {
+ "type": "nGram",
+ "min_gram": 3,
+ "max_gram": 15
+ },
+ "edgengram": {
+ "type": "edgeNGram",
+ "min_gram": 1,
+ "max_gram": 15
+ }
+ }
+ }
+ }
+ }
+
+ # Create new index
+ self.es.create_index(self.es_index, INDEX_SETTINGS)
+
+ def add_type(self, model):
+ # Make sure that the model is indexed
+ if not model.indexed:
+ return
+
+ # Get type name
+ content_type = model.indexed_get_content_type()
+
+ # Get indexed fields
+ indexed_fields = model.indexed_get_indexed_fields()
+
+ # Make field list
+ fields = dict({
+ "pk": dict(type="string", index="not_analyzed", store="yes"),
+ "content_type": dict(type="string"),
+ }.items() + indexed_fields.items())
+
+ # Put mapping
+ self.es.put_mapping(self.es_index, content_type, {
+ content_type: {
+ "properties": fields,
+ }
+ })
+
+ def refresh_index(self):
+ self.es.refresh(self.es_index)
+
+ def can_be_indexed(self, obj):
+ # Object must be a decendant of Indexed and be a django model
+ if not isinstance(obj, Indexed) or not isinstance(obj, models.Model):
+ return False
+
+ # Check if this objects model has opted out of indexing
+ if not obj.__class__.indexed:
+ return False
+
+ # Check if this object has an "object_indexed" function
+ if hasattr(obj, "object_indexed"):
+ if obj.object_indexed() == False:
+ return False
+ return True
+
+ def add(self, obj):
+ # Make sure the object can be indexed
+ if not self.can_be_indexed(obj):
+ return
+
+ # Build document
+ doc = obj.indexed_build_document()
+
+ # Add to index
+ self.es.index(self.es_index, obj.indexed_get_content_type(), doc, id=doc["id"])
+
+ def add_bulk(self, obj_list):
+ # Group all objects by their type
+ type_set = {}
+ for obj in obj_list:
+ # Object must be a decendant of Indexed and be a django model
+ if not self.can_be_indexed(obj):
+ continue
+
+ # Get object type
+ obj_type = obj.indexed_get_content_type()
+
+ # If type is currently not in set, add it
+ if obj_type not in type_set:
+ type_set[obj_type] = []
+
+ # Add object to set
+ type_set[obj_type].append(obj.indexed_build_document())
+
+ # Loop through each type and bulk add them
+ for type_name, type_objects in type_set.items():
+ print type_name, len(type_objects)
+ self.es.bulk_index(self.es_index, type_name, type_objects)
+
+ def delete(self, obj):
+ # Object must be a decendant of Indexed and be a django model
+ if not isinstance(obj, Indexed) or not isinstance(obj, models.Model):
+ return
+
+ # Get ID for document
+ doc_id = obj.indexed_get_document_id()
+
+ # Delete document
+ try:
+ self.es.delete(self.es_index, obj.indexed_get_content_type(), doc_id)
+ except ElasticHttpNotFoundError:
+ pass # Document doesn't exist, ignore this exception
+
+ def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]):
+ # Model must be a descendant of Indexed and be a django model
+ if not issubclass(model, Indexed) or not issubclass(model, models.Model):
+ return []
+
+ # Clean up query string
+ query_string = "".join([c for c in query_string if c not in string.punctuation])
+
+ # Check that theres still a query string after the clean up
+ if not query_string:
+ return []
+
+ # Query
+ if fields:
+ query = self.s.query_raw({
+ "query_string": {
+ "query": query_string,
+ "fields": fields,
+ }
+ })
+ else:
+ query = self.s.query_raw({
+ "query_string": {
+ "query": query_string,
+ }
+ })
+
+ # Filter results by this content type
+ query = query.filter(content_type__prefix=model.indexed_get_content_type())
+
+ # Extra filters
+ if filters:
+ query = query.filter(**filters)
+
+ # Return search results
+ return SearchResults(model, query, prefetch_related=prefetch_related)
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/searcher.py b/wagtail/wagtailsearch/searcher.py
new file mode 100644
index 000000000..d8103c632
--- /dev/null
+++ b/wagtail/wagtailsearch/searcher.py
@@ -0,0 +1,14 @@
+from search import Search
+
+
+class Searcher(object):
+ def __init__(self, fields, filters=dict(), **kwargs):
+ self.fields = fields
+ self.filters = filters
+
+ def __get__(self, instance, cls):
+ def dosearch(query_string, **kwargs):
+ search_kwargs = dict(model=cls, fields=self.fields, filters=self.filters)
+ search_kwargs.update(kwargs)
+ return Search().search(query_string, **search_kwargs)
+ return dosearch
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/signal_handlers.py b/wagtail/wagtailsearch/signal_handlers.py
new file mode 100644
index 000000000..ea8246c57
--- /dev/null
+++ b/wagtail/wagtailsearch/signal_handlers.py
@@ -0,0 +1,23 @@
+from django.dispatch import Signal
+from django.db.models.signals import post_save, post_delete
+from django.db import models
+from search import Search
+from indexed import Indexed
+
+
+def post_save_signal_handler(instance, **kwargs):
+ Search().add(instance)
+
+
+def post_delete_signal_handler(instance, **kwargs):
+ Search().delete(instance)
+
+
+def register_signal_handlers():
+ # Get list of models that should be indexed
+ indexed_models = [model for model in models.get_models() if issubclass(model, Indexed)]
+
+ # Loop through list and register signal handlers for each one
+ for model in indexed_models:
+ post_save.connect(post_save_signal_handler, sender=model)
+ post_delete.connect(post_delete_signal_handler, sender=model)
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html b/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html
new file mode 100644
index 000000000..58ec7e3a0
--- /dev/null
+++ b/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html
@@ -0,0 +1,43 @@
+{% extends "wagtailadmin/base.html" %}
+{% block titletag %}Add editors pick{% endblock %}
+{% block content %}
+
+
Add editors pick
+
+
+
+
Editors picks are a means of recommending specific pages that might not organically come high up in search results. E.g recommending your primary donation page to a user searching with a less common term like "giving".
+
+
The "Search term(s)/phrase" field below must contain the full and exact search for which you wish to provide recommended results, including any misspellings/user error. To help, you can choose from search terms that have been popular with users of your site.
+
+
+
+{% endblock %}
+
+{% block extra_css %}
+ {% include "wagtailadmin/pages/_editor_css.html" %}
+{% endblock %}
+{% block extra_js %}
+ {% include "wagtailadmin/pages/_editor_js.html" %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/confirm_delete.html b/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/confirm_delete.html
new file mode 100644
index 000000000..58930bfef
--- /dev/null
+++ b/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/confirm_delete.html
@@ -0,0 +1,15 @@
+{% extends "wagtailadmin/base.html" %}
+
+{% block titletag %}Delete {{ query.query_string }}{% endblock %}
+{% block content %}
+
+
Delete {{ query.query_string }}
+
+
+
Are you sure you want to delete all editors picks for this search term?
+ {% for editors_pick in query.editors_picks.all %}
+ {{ editors_pick.page.title }}{% if not forloop.last %}, {% endif %}
+ {% empty %}
+ None
+ {% endfor %}
+
+
{{ query.hits }}
+
+ {% endfor %}
+ {% else %}
+
No editors picks have been added. Why not add one?
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/queries/chooser/chooser.html b/wagtail/wagtailsearch/templates/wagtailsearch/queries/chooser/chooser.html
new file mode 100644
index 000000000..ebbbc6aa1
--- /dev/null
+++ b/wagtail/wagtailsearch/templates/wagtailsearch/queries/chooser/chooser.html
@@ -0,0 +1,17 @@
+
+
Popular search terms
+
+
+
+
+
+ {% include "wagtailsearch/queries/chooser/results.html" %}
+