Elastic Search + Django

Elastic Search + Django

·

6 min read

Requirements:

Project Setup:


$ mkdir dj_elastic && cd dj_elastic
$ python3 -m venv env
$ source env/bin/activate
$ poetry init
$ poetry add django djangorestframework django-autoslug black isort
$ poetry add django-haystack drf-haystack
$ poetry add elasticsearch==^5.0
$ django-admin.py startproject main
$ python manage.py startapp searches
$ python manage.py startapp commons

project directory should look like:

── dj_elastic
├── main
│ ├── **init**.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
└── commons
└── searches

Main app /urls.py

from django.contrib import admin
from django.urls import path
from django.urls.conf import include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/v1/", include("searches.urls")),
]

main/settings.py

INSTALLED_APPS = [
    "searches",
    "commons",
    "haystack",
    "rest_framework",
]
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]
HAYSTACK_CONNECTIONS = {
    "default": {
        "ENGINE": "haystack.backends.elasticsearch5_backend.Elasticsearch5SearchEngine",
        "URL": "http://127.0.0.1:9200/",
        "INDEX_NAME": "haystack",
    },
}

HAYSTACK_SIGNAL_PROCESSOR = "haystack.signals.RealtimeSignalProcessor"

👏🏻 Great, finished with basic setups.... Next, lets create models. Navigate to commons/models.py

# commons/models.py

from django.db import models
from autoslug import AutoSlugField
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _


def slugify(value):
    return value.replace(" ", "-").lower()


class ConfigChoiceCategory(models.Model):

    name = models.CharField(
        _("Config Choice Category Name"),
        help_text=_("Required and Unique"),
        max_length=255,
        unique=True,
    )
    slug = AutoSlugField(
        verbose_name=_("Config Choice Category Slug"),
        populate_from="name",
        slugify=slugify,
    )
    entered_by = models.ForeignKey(User, blank=True, on_delete=models.CASCADE)
    is_active = models.BooleanField(default=True)

    class Meta:
        verbose_name = _("Config Choice Category")
        verbose_name_plural = _(" Config Choice Categories")

    def __str__(self):
        return self.name


class ConfigChoice(models.Model):
    name = models.CharField(
        _("Config Choice Name"),
        help_text=_("Required and Unique"),
        max_length=255,
        unique=True,
    )
    description = models.TextField()
    slug = AutoSlugField(
        verbose_name=_("Config Choice Slug"),
        populate_from="name",
        slugify=slugify,
    )
    config_choice_category = models.ForeignKey(
        ConfigChoiceCategory, on_delete=models.CASCADE
    )
    entered_by = models.ForeignKey(User, on_delete=models.CASCADE)

    class Meta:
        verbose_name = _("Config Choice")
        verbose_name_plural = _("Config Choices")

    def __str__(self) -> str:
        return self.name


class Address(models.Model):
    street_1 = models.CharField(max_length=200)
    street_2 = models.CharField(max_length=200, null=True, blank=True)
    city = models.CharField(max_length=100)
    state = models.CharField(max_length=100)
    zip_code = models.CharField(max_length=100)
    country = models.CharField(max_length=50)
    latitude = models.FloatField()
    longitude = models.FloatField()

    def __str__(self):
        return f"{self.street_1}, {self.city}, {self.state}, {self.country}"``

Here, we:

  • Created a models ConfigChoiceCategory and ConfigChoice, where configchoice has relation with ConfigChoiceCategory.
  • And we have Address Model too

Register models to admin.py

from django.contrib import admin

# Register your models here.

from .models import (
    Address,
    ConfigChoice,
    ConfigChoiceCategory,
)


admin.site.register(ConfigChoiceCategory)
admin.site.register(ConfigChoice)
admin.site.register(Address)

So, let's navigate to searches app and create models for hotels.

#searches/models.py

from commons.models import Address, ConfigChoice
from django.db import models
from django.utils.translation import gettext_lazy as _

from autoslug import AutoSlugField


def slugify(value):
    return value.replace(" ", "-").lower()


class CoreModel(models.Model):

    created = models.DateTimeField(auto_now_add=True)
    modified = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class HotelType(models.Model):
    name = models.CharField(_("Hotel Types Name"), max_length=255)

    class Meta:
        verbose_name = _("Hotel Type")
        verbose_name_plural = _("Hotel Types")

    def __str__(self) -> str:
        return self.name


class HotelSpecifications(models.Model):
    hotel_type = models.ForeignKey(HotelType, on_delete=models.RESTRICT)
    name = models.CharField(_("Hotel Spec Name"), max_length=255)

    class Meta:
        verbose_name = _("Hotel Specification")
        verbose_name_plural = _("Hotel Specifications")

    def __str__(self) -> str:
        return f"{self.name}"


class Hotel(CoreModel):
    name = models.CharField(_("Hotel Name"), max_length=50)
    description = models.TextField(_("Hotel Descriptions"), default="")
    hotel_type = models.ForeignKey(HotelType, on_delete=models.CASCADE)
    slug = AutoSlugField(
        verbose_name=_("Hotel Slug"),
        populate_from="name",
        slugify=slugify,
    )
    is_active = models.BooleanField(default=True)
    config_choice = models.ForeignKey(ConfigChoice, on_delete=models.RESTRICT)

    class Meta:
        verbose_name = _("Hotel")
        verbose_name_plural = _("Hotels")

    def get_absolute_url(self):
        return f"/{self.slug}/"

    def __str__(self) -> str:
        return self.name


class HotelSpecificationValue(models.Model):
    hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE)
    specification = models.ForeignKey(HotelSpecifications, on_delete=models.RESTRICT)
    value = models.CharField(
        _("Value"),
        max_length=255,
        help_text=_("Hotel specification value (maximum of 255 words"),
    )

    class Meta:
        verbose_name = _("Hotel Specification Value")
        verbose_name_plural = _("Hotel Specification Values")

    def __str__(self):
        return self.value


class HotelImage(CoreModel):
    hotel = models.ForeignKey(
        Hotel, on_delete=models.CASCADE, related_name="hotel_image"
    )
    image_urls = models.URLField(
        _("Hotel Image URLs"),
        help_text=_("Images Urls"),
    )
    caption = models.CharField(
        verbose_name=_("Alternative text"),
        help_text=_("Please add alturnative text"),
        max_length=255,
        null=True,
        blank=True,
    )
    is_feature = models.BooleanField(default=False)

    class Meta:
        verbose_name = _("Hotel Image")
        verbose_name_plural = _("Hotel Images")


class HotelAddress(models.Model):
    hotel = models.ForeignKey(
        Hotel, on_delete=models.CASCADE, related_name="hotel_address"
    )
    address = models.ForeignKey(Address, on_delete=models.CASCADE)

    def __str__(self):
        return f"{self.hotel.name} {self.address.city}"

Registering models to admin.py

from django.contrib import admin

from .models import (
    Hotel,
    HotelImage,
    HotelSpecifications,
    HotelSpecificationValue,
    HotelType,
    HotelAddress,
)


class HotelSpecificationInline(admin.TabularInline):
    model = HotelSpecifications


@admin.register(HotelType)
class HotelTypeAdmin(admin.ModelAdmin):
    inlines = [
        HotelSpecificationInline,
    ]


class HotelImageInline(admin.TabularInline):
    model = HotelImage


class HotelSpecificationValueInline(admin.TabularInline):
    model = HotelSpecificationValue


@admin.register(Hotel)
class HotelAdmin(admin.ModelAdmin):
    inlines = [HotelSpecificationValueInline, HotelImageInline]


admin.site.register(HotelAddress)

create a file search_indexes.py inside searches app.

#searches/search_indexes.py

from django.utils import timezone
from haystack import indexes

from .models import Hotel, HotelAddress, HotelImage, HotelSpecificationValue


class HotelIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True, use_template=True)
    name = indexes.CharField(model_attr="name")
    hotel_type = indexes.CharField(model_attr="hotel_type")
    config_choice = indexes.CharField(model_attr="config_choice")
    autocomplete = indexes.EdgeNgramField()

    @staticmethod
    def prepare_autocomplete(obj):
        return " ".join((obj.name, obj.hotel_type.name, obj.config_choice.name))

    def get_model(self):
        return Hotel


class HotelSpecIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True, use_template=True)
    value = indexes.CharField(model_attr="value")

    def get_model(self):
        return HotelSpecificationValue


class HotelImageIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True, use_template=True)
    image_urls = indexes.CharField(model_attr="image_urls")
    caption = indexes.CharField(model_attr="caption")

    def get_model(self):
        return HotelImage


class HotelAddressIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True, use_template=True)
    address = indexes.CharField(model_attr="address")

    def get_model(self):
        return HotelAddress
  • Creates a unique SearchIndex for each type of Model you wish to index, though you can reuse the same SearchIndex between different models if you take care in doing so and your field names are very standardized.
  • To build a SearchIndex, all that’s necessary is to subclass both indexes.SearchIndex & indexes.Indexable, define the fields you want to store data with and define a get_model method.

Serialization and views:

#searches/serializers.py

from drf_haystack.serializers import HaystackSerializer

from .search_indexes import (
    HotelIndex,
    HotelSpecIndex,
    HotelImageIndex,
    HotelAddressIndex,
)


class AggregateSerializer(HaystackSerializer):
    class Meta:
        index_classes = [HotelIndex, HotelSpecIndex, HotelImageIndex, HotelAddressIndex]
        fields = [
            "name",
            "hotel",
            "config_choice",
            "value",
            "image_urls",
            "caption",
            "address",
            "autocomplete",
        ]
# searches/serializers.py

from .serializers import AggregateSerializer
from rest_framework.mixins import ListModelMixin
from drf_haystack.generics import HaystackGenericAPIView


class AggregateSearchViewSet(ListModelMixin, HaystackGenericAPIView):

    serializer_class = AggregateSerializer

    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

so you can create a each class of serializers for each models Like this.

# searches/urls.py

from django.urls import path

from .views import AggregateSearchViewSet


urlpatterns = [
path("hotels/search/", AggregateSearchViewSet.as_view())
]

Create a templates directory inside searches app. Templates folder will look like this:

templates
    ├── search
        ├── indexes
            ├── searches
                ├── hotel_text.txt
                ├── hoteladdress_text.txt
                ├── hotelimage_text.txt
                ├── hotelspecificationvalue_text.txt

Finally migrate your apps, createsuperuser and add some hotels data using django admin panels. Simply run

./manage.py rebuild_index.

You’ll get some totals of how many models were processed and placed in the index.

Query time!

Now that we have a view wired up, we can start using it. By default, the HaystackGenericAPIView class is set up to use the HaystackFilter. This is the most basic filter included and can do basic search by querying any of the field included in the fields attribute on the Serializer.

http://127.0.0.1:8000/api/v1/hotels/search/

[
    {
        "image_urls": "https://images.moviesanywhere.com/8ccb2868a61ac0612d780eb3b18e5220/6fda2dc9-a774-4ba6-9e80-679accfcc8ed.jpg?h=375&resize=fit&w=250",
        "caption": "img"
    },
    {
        "name": "Transylvania Hotal",
        "config_choice": "Active",
        "autocomplete": "Transylvania Hotal 3 Star Active"
    },
    {
        "value": "12 AD"
    },
    {
        "value": "Monsters Hotel"
    },
    {
        "address": "US"
    },
    {
        "value": "12 AD"
    },
    {
        "value": "gogogog"
    },
    {
        "image_urls": "https://images.moviesanywhere.com/8ccb2868a61ac0612d780eb3b18e5220/6fda2dc9-a774-4ba6-9e80-679accfcc8ed.jpg?h=375&resize=fit&w=250",
        "caption": "img"
    },
    {
        "value": "lONG LONG TIME AGO"
    },
    {
        "name": "demo",
        "config_choice": "Active",
        "autocomplete": "demo 3 Star Active"
    },
    {
        "value": "lONG LONG TIME AGO"
    }
]


http://127.0.0.1:8000/api/v1/hotels/search/?name="demo"

"results": [
        {
            "name": "demo",
            "config_choice": "Active",
            "autocomplete": "demo 3 Star Active"
        }
    ]