Filtering derived fields with Wagtail search

Wagtail’s built in search functionality has this nifty feature where it will index callables on your model, e.g. your model has a start and end date, but you want to search on duration. Hypothetically we could add a FilterField here to index this for Elasticsearch ((Note the requirement for a type, otherwise this will be indexed as a string.)).

[python]
class Job(Model, index.Indexed):
"""A job you can apply for."""

start_date = models.DateField()
end_date = models.DateField()

search_fields = (
index.FilterField(‘duration’, type=’IntegerField’),
)

@property
def duration(self):
"""Duration of the assignment."""
return 12 * (self.end_date.year – self.start_date.year) + \
self.end_date.month – self.start_date.month

[/python]

Wagtail is quite clever in that it takes a Django QuerySet and decomposes the filters.

[python]
queryset = queryset\
.filter(duration__range=(data[‘duration’].lower or 0,
data[‘duration’].upper or 99999))
query = backend.search(keywords, queryset)
[/python]

Of course Django will get upset about this, since that’s not a field you can filter on. So we can annotate the value in.

[python highlight=”14-16″]
from django.db.models import Func, fields

class DurationInMonths(Func): # pylint:disable=abstract-method
"""
SQL Function to calculate the duration of assignments in months.
"""
template = \
‘EXTRACT(year FROM age(%(expressions)s)) * 12 + ‘ \
‘EXTRACT(month FROM age(%(expressions)s))’
output_field = fields.IntegerField()

queryset = queryset\
.annotate(duration=DurationInMonths(
F(‘end_date’), F(‘start_date’)
))\
.filter(duration__range=(data[‘duration’].lower or 0,
data[‘duration’].upper or 99999))
[/python]

Also Django will be upset it can’t annotate the duration, so you’ll need to add a setter to your model.

[python highlight=”6-9″]
class Job(Model, index.Indexed):
@property
def duration(self):

@duration.setter
def duration(self, value):
"""Ignored to make Django annotations happy."""
pass
[/python]

And you ideally would be done except now Wagtail gets upset that it can’t determine the attribute name for the field but you can kludge around this for now:

[python highlight=”9-11″]
class DurationInMonths(Func): # pylint:disable=abstract-method
"""
SQL Function to calculate the duration of assignments in months.
"""
template = \
‘EXTRACT(year FROM age(%(expressions)s)) * 12 + ‘ \
‘EXTRACT(month FROM age(%(expressions)s))’
output_field = fields.IntegerField()
target = type(‘IntegerFieldKludge’,
(fields.IntegerField,),
{‘attname’: ‘duration’})()
[/python]

Multiple choice using Django’s postgres ArrayField

There’s a lot of times you want to have a multiple choice set of flags and using a many-to-many database relation is massively overkill. Django 1.9 added a builtin modelfield to leverage Postgres’ built-in array support. Unfortunately the default formfield for ArrayField, SimpleArrayField, is not even a little bit useful (it’s a comma-delimited text input).

If you’re writing your own form, you can simply use the MultipleChoiceField formfield, but if you’re using something that builds forms using the ModelForm automagic factories with no overrides (e.g. Wagtail’s admin site), you need a way to specify the formfield.

Instead subclass ArrayField:

[python]
from django import forms
from django.contrib.postgres.fields import ArrayField

class ChoiceArrayField(ArrayField):
"""
A field that allows us to store an array of choices.

Uses Django 1.9’s postgres ArrayField
and a MultipleChoiceField for its formfield.
"""

def formfield(self, **kwargs):
defaults = {
‘form_class’: forms.MultipleChoiceField,
‘choices’: self.base_field.choices,
}
defaults.update(kwargs)
# Skip our parent’s formfield implementation completely as we don’t
# care for it.
# pylint:disable=bad-super-call
return super(ArrayField, self).formfield(**defaults)
[/python]

You use this like ArrayField, except that choices is required.

[python]
FLAG_CHOICES = (
(‘defect’, ‘Defect’),
(‘enhancement’, ‘Enhancement’),
)
flags = ChoiceArrayField(models.CharField(max_length=…,
choices=FLAG_CHOICES),
default=[‘defect’])
[/python]

You can similarly use this with any other field that supports choices, e.g. IntegerField (but you’re not storing choices as integers… are you).

Gist.

Creative Commons Attribution-ShareAlike 2.5 Australia
This work by Danielle Madeley is licensed under a Creative Commons Attribution-ShareAlike 2.5 Australia.