Repository: django-oscar/django-oscar-elasticsearch (language: python)

Issue:
Queries become less once you remove transactions.
Change test to reflect the new num queries now that that transaction is removed.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml
index 13b65eb..5124561 100644
--- a/.github/workflows/django.yml
+++ b/.github/workflows/django.yml
@@ -24,7 +24,7 @@ jobs:
         sudo sysctl -w vm.max_map_count=262144
     - uses: elastic/elastic-github-actions/elasticsearch@master
       with:
-        stack-version: 6.8.2
+        stack-version: 8.15.0
     - uses: actions/checkout@v3
     - name: Set up Python ${{ matrix.python-version }}
       uses: actions/setup-python@v3
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 0000000..6b539bf
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,73 @@
+#!/usr/bin/env groovy
+
+pipeline {
+    agent any
+    options { disableConcurrentBuilds() }
+
+    stages {
+        stage('Build') {
+            steps {
+                withEnv(['PIP_INDEX_URL=https://pypi.uwkm.nl/voxyan/oscar/+simple/']) {
+                    withPythonEnv('System-CPython-3.10') {
+                        pysh "make"
+                    }
+                }
+            }
+        }
+        stage('Lint') {
+            steps {
+                withPythonEnv('System-CPython-3.10') {
+                    pysh "make lint"
+                }
+            }
+        }
+        stage('Test') {
+            steps {
+                withPythonEnv('System-CPython-3.10') {
+                    withEnv(['OSCAR_ELASTICSEARCH_SERVER_URLS=https://eden.highbiza.nl:9200/', 'PIP_INDEX_URL=https://pypi.uwkm.nl/voxyan/oscar/+simple/']) {
+                        pysh "make test"
+                    }
+                }
+            }
+            post {
+                always {
+                    junit allowEmptyResults: true, testResults: '**/nosetests.xml'
+                }
+                success {
+                    echo "kek!"
+                    // step([
+                    //     $class: 'CoberturaPublisher',
+                    //     coberturaReportFile: '**/coverage.xml',
+                    // ])
+                }
+            }
+        }
+    }
+    post {
+        always {
+            echo 'This will always run'
+        }
+        success {
+            echo 'This will run only if successful'
+            withPythonEnv('System-CPython-3.10') {
+                echo 'This will run only if successful'
+                pysh "version --plugin=wheel -B${env.BUILD_NUMBER} --skip-build"
+                sh "which git"
+                sh "git push --tags"
+            }
+        }
+        failure {
+            emailext subject: "JENKINS-NOTIFICATION: ${currentBuild.currentResult}: Job '${env.JOB_NAME} #${env.BUILD_NUMBER}'",
+                    body: '${SCRIPT, template="groovy-text.template"}',
+                    recipientProviders: [culprits(), brokenBuildSuspects(), brokenTestsSuspects()]
+
+        }
+        unstable {
+            echo 'This will run only if the run was marked as unstable'
+        }
+        changed {
+            echo 'This will run only if the state of the Pipeline has changed'
+            echo 'For example, if the Pipeline was previously failing but is now successful'
+        }
+    }
+}
diff --git a/Makefile b/Makefile
index 0384a82..2396967 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ all: install migrate loaddata collectstatic
 
 
 install: fail-if-no-virtualenv
-	pip install --pre --editable .[dev] --upgrade --upgrade-strategy=eager
+	pip install --pre --editable .[dev,test] --upgrade --upgrade-strategy=eager
 
 migrate:
 	sandbox/manage.py migrate --no-input
@@ -22,3 +22,14 @@ test:
 
 black:
 	black --exclude "migrations/*" setup.py oscar_elasticsearch
+
+clean: ## Remove files not in source control
+	find . -type f -name "*.pyc" -delete
+	rm -rf nosetests.xml coverage.xml htmlcov *.egg-info *.pdf dist violations.txt
+
+package: clean
+	pip install --upgrade pip twine wheel
+	rm -rf dist/
+	rm -rf build/
+	python setup.py clean --all
+	python setup.py sdist bdist_wheel
diff --git a/oscar_elasticsearch/exceptions.py b/oscar_elasticsearch/exceptions.py
new file mode 100644
index 0000000..0be7ad4
--- /dev/null
+++ b/oscar_elasticsearch/exceptions.py
@@ -0,0 +1,2 @@
+class ElasticSearchQueryException(Exception):
+    pass
diff --git a/oscar_elasticsearch/search/migrations/__init__.py b/oscar_elasticsearch/search/api/__init__.py
similarity index 100%
rename from oscar_elasticsearch/search/migrations/__init__.py
rename to oscar_elasticsearch/search/api/__init__.py
diff --git a/oscar_elasticsearch/search/api/autocomplete.py b/oscar_elasticsearch/search/api/autocomplete.py
new file mode 100644
index 0000000..3fc45a6
--- /dev/null
+++ b/oscar_elasticsearch/search/api/autocomplete.py
@@ -0,0 +1,40 @@
+from oscar.core.loading import get_class
+
+from oscar_elasticsearch.search.settings import NUM_SUGGESTIONS
+
+es = get_class("search.backend", "es")
+
+
+def get_option_results(results):
+    for suggestion in results["suggest"]["autocompletion"]:
+        for option in suggestion["options"]:
+            yield option["text"]
+
+
+def autocomplete_suggestions(
+    index,
+    search_string,
+    suggest_field_name,
+    skip_duplicates=True,
+    contexts=None,
+    num_suggestions=NUM_SUGGESTIONS,
+):
+    body = {
+        "suggest": {
+            "autocompletion": {
+                "prefix": search_string,
+                "completion": {
+                    "field": suggest_field_name,
+                    "skip_duplicates": skip_duplicates,
+                },
+            }
+        },
+        "_source": False,
+    }
+
+    if contexts is not None:
+        body["suggest"]["autocompletion"]["completion"]["contexts"] = contexts
+
+    results = es.search(index=index, body=body)
+
+    return list(get_option_results(results))[:num_suggestions]
diff --git a/oscar_elasticsearch/search/api/base.py b/oscar_elasticsearch/search/api/base.py
new file mode 100644
index 0000000..c62da6e
--- /dev/null
+++ b/oscar_elasticsearch/search/api/base.py
@@ -0,0 +1,17 @@
+class BaseModelIndex:
+    INDEX_NAME = None
+    INDEX_MAPPING = None
+    INDEX_SETTINGS = None
+    Model = None
+
+    def get_index_name(self):
+        return self.INDEX_NAME
+
+    def get_index_mapping(self):
+        return self.INDEX_MAPPING
+
+    def get_index_settings(self):
+        return self.INDEX_SETTINGS
+
+    def get_model(self):
+        return self.Model
diff --git a/oscar_elasticsearch/search/api/category.py b/oscar_elasticsearch/search/api/category.py
new file mode 100644
index 0000000..11e90b6
--- /dev/null
+++ b/oscar_elasticsearch/search/api/category.py
@@ -0,0 +1,43 @@
+from odin.codecs import dict_codec
+
+from oscar.core.loading import get_class, get_model
+
+# this index name is retrived with get_class because of i18n but it might be removed later
+OSCAR_CATEGORIES_INDEX_NAME = get_class(
+    "search.indexing.settings", "OSCAR_CATEGORIES_INDEX_NAME"
+)
+get_categories_index_mapping = get_class(
+    "search.indexing.settings", "get_categories_index_mapping"
+)
+get_oscar_index_settings = get_class(
+    "search.indexing.settings", "get_oscar_index_settings"
+)
+
+
+OSCAR_INDEX_SETTINGS = get_oscar_index_settings()
+
+BaseElasticSearchApi = get_class("search.api.search", "BaseElasticSearchApi")
+ESModelIndexer = get_class("search.indexing.indexer", "ESModelIndexer")
+
+Category = get_model("catalogue", "Category")
+
+
+class CategoryElasticsearchIndex(BaseElasticSearchApi, ESModelIndexer):
+    INDEX_NAME = OSCAR_CATEGORIES_INDEX_NAME
+    INDEX_MAPPING = get_categories_index_mapping()
+    INDEX_SETTINGS = OSCAR_INDEX_SETTINGS
+    Model = Category
+
+    def make_documents(self, objects):
+        from oscar_odin.mappings import catalogue
+
+        CategoryElasticSearchMapping = get_class(
+            "search.mappings.categories", "CategoryElasticSearchMapping"
+        )
+
+        category_resources = catalogue.CategoryToResource.apply(objects)
+        category_document_resources = CategoryElasticSearchMapping.apply(
+            category_resources
+        )
+
+        return dict_codec.dump(category_document_resources, include_type_field=False)
diff --git a/oscar_elasticsearch/search/api/pagination.py b/oscar_elasticsearch/search/api/pagination.py
new file mode 100644
index 0000000..9895b04
--- /dev/null
+++ b/oscar_elasticsearch/search/api/pagination.py
@@ -0,0 +1,20 @@
+from django.core.paginator import Paginator
+
+
+class ElasticSearchPaginator(Paginator):
+    def __init__(self, instances, *args, **kwargs):
+        self.instances = instances
+        super().__init__(*args, **kwargs)
+
+    def page(self, number):
+        """Return a Page object for the given 1-based page number."""
+        number = self.validate_number(number)
+        bottom = (number - 1) * self.per_page
+        top = bottom + self.per_page
+        if top + self.orphans >= self.count:
+            top = self.count
+        return self._get_page(self.instances, number, self)
+
+
+def paginate_result(instances, total_hits, size):
+    return ElasticSearchPaginator(instances, range(0, total_hits), size)
diff --git a/oscar_elasticsearch/search/api/product.py b/oscar_elasticsearch/search/api/product.py
new file mode 100644
index 0000000..926bc8f
--- /dev/null
+++ b/oscar_elasticsearch/search/api/product.py
@@ -0,0 +1,81 @@
+from odin.codecs import dict_codec
+
+from django.db.models import QuerySet, Count, Q
+from django.utils import timezone
+
+from dateutil.relativedelta import relativedelta
+
+from oscar.core.loading import get_class, get_model, get_classes
+from oscar_elasticsearch.search import settings
+
+# this index name is retrived with get_class because of i18n but it might be removed later
+(
+    OSCAR_PRODUCTS_INDEX_NAME,
+    OSCAR_PRODUCT_SEARCH_FIELDS,
+    get_products_index_mapping,
+    get_oscar_index_settings,
+) = get_classes(
+    "search.indexing.settings",
+    [
+        "OSCAR_PRODUCTS_INDEX_NAME",
+        "OSCAR_PRODUCT_SEARCH_FIELDS",
+        "get_products_index_mapping",
+        "get_oscar_index_settings",
+    ],
+)
+BaseElasticSearchApi = get_class("search.api.search", "BaseElasticSearchApi")
+ESModelIndexer = get_class("search.indexing.indexer", "ESModelIndexer")
+Product = get_model("catalogue", "Product")
+Line = get_model("order", "Line")
+
+
+class ProductElasticsearchIndex(BaseElasticSearchApi, ESModelIndexer):
+    Model = Product
+    INDEX_NAME = OSCAR_PRODUCTS_INDEX_NAME
+    INDEX_MAPPING = get_products_index_mapping()
+    INDEX_SETTINGS = get_oscar_index_settings()
+    SEARCH_FIELDS = OSCAR_PRODUCT_SEARCH_FIELDS
+    SUGGESTION_FIELD_NAME = settings.SUGGESTION_FIELD_NAME
+
+    def get_filters(self, filters):
+        if filters is not None:
+            return filters
+
+        return [{"term": {"is_public": True}}]
+
+    def make_documents(self, objects):
+        if not isinstance(objects, QuerySet):
+            try:
+                objects = Product.objects.filter(id__in=[o.id for o in objects])
+            except:
+                # pylint: disable=raise-missing-from
+                raise ValueError(
+                    "Argument 'objects' must be a QuerySet, as product_queryset_to_resources requires a QuerySet, got %s"
+                    % type(objects)
+                )
+
+        from oscar_odin.mappings import catalogue
+
+        ProductElasticSearchMapping = get_class(
+            "search.mappings.products", "ProductElasticSearchMapping"
+        )
+
+        # Annotate the queryset with popularity to avoid the need of n+1 queries
+        objects = objects.annotate(
+            popularity=Count(
+                "line",
+                filter=Q(
+                    line__order__date_placed__gte=timezone.now()
+                    - relativedelta(months=settings.MONTHS_TO_RUN_ANALYTICS)
+                ),
+            )
+        )
+
+        product_resources = catalogue.product_queryset_to_resources(
+            objects, include_children=True
+        )
+        product_document_resources = ProductElasticSearchMapping.apply(
+            product_resources
+        )
+
+        return dict_codec.dump(product_document_resources, include_type_field=False)
diff --git a/oscar_elasticsearch/search/api/search.py b/oscar_elasticsearch/search/api/search.py
new file mode 100644
index 0000000..8a18e14
--- /dev/null
+++ b/oscar_elasticsearch/search/api/search.py
@@ -0,0 +1,468 @@
+# pylint: disable=W0102
+from oscar.core.loading import get_class
+from django.conf import settings
+from oscar_elasticsearch.exceptions import ElasticSearchQueryException
+from oscar_elasticsearch.search import settings as es_settings
+from oscar_elasticsearch.search.api.base import BaseModelIndex
+from oscar_elasticsearch.search.utils import search_result_to_queryset
+
+paginate_result = get_class("search.api.pagination", "paginate_result")
+es = get_class("search.backend", "es")
+
+
+def get_search_query(
+    search_fields=None, query_string=None, search_type=None, search_operator=None
+):
+    if query_string:
+        return [
+            {
+                "multi_match": {
+                    "query": query_string,
+                    "type": search_type,
+                    "operator": search_operator,
+                    "fields": search_fields,
+                }
+            }
+        ]
+
+    else:
+        return {"match_all": {}}
+
+
+def get_search_body(
+    from_=None,
+    size=None,
+    search_fields=None,
+    query_string=None,
+    filters=None,
+    sort_by=None,
+    suggestion_field_name=None,
+    search_type=None,
+    search_operator=None,
+    scoring_functions=None,
+    aggs=None,
+    explain=True,
+):
+    body = {
+        "query": {
+            "function_score": {
+                "query": {
+                    "bool": {
+                        "must": get_search_query(
+                            search_fields if search_fields is not None else [],
+                            query_string,
+                            search_type,
+                            search_operator,
+                        ),
+                        "filter": filters,
+                    }
+                },
+                "functions": scoring_functions if scoring_functions is not None else [],
+            }
+        }
+    }
+
+    if explain:
+        body["explain"] = settings.DEBUG
+
+    if from_:
+        body["from"] = from_
+
+    if size:
+        body["size"] = size
+
+    if sort_by:
+        body["sort"] = sort_by
+
+    if aggs:
+        body["aggs"] = aggs
+
+    if suggestion_field_name and query_string:
+        body["suggest"] = {
+            suggestion_field_name: {
+                "prefix": query_string,
+                "term": {"field": suggestion_field_name},
+            }
+        }
+
+    return body
+
+
+def do_count(
+    index,
+    query_string=None,
+    search_fields=None,
+    filters=None,
+    search_type=None,
+    search_operator=None,
+):
+    body = get_search_body(
+        query_string=query_string,
+        search_fields=search_fields,
+        filters=filters,
+        search_type=search_type,
+        search_operator=search_operator,
+        explain=False,
+    )
+
+    result = es.count(index=index, body=body)
+
+    return result.get("count", 0)
+
+
+def get_elasticsearch_aggs(aggs_definitions):
+    aggs = {}
+
+    for facet_definition in aggs_definitions:
+        name = facet_definition["name"]
+        facet_type = facet_definition["type"]
+        if facet_type == "term":
+            terms = {"terms": {"field": name, "size": es_settings.FACET_BUCKET_SIZE}}
+
+            if "order" in facet_definition:
+                terms["terms"]["order"] = {"_key": facet_definition.get("order", "asc")}
+
+            aggs[name] = terms
+        elif facet_type == "range":
+            ranges_definition = facet_definition["ranges"]
+            if ranges_definition:
+                ranges = [
+                    (
+                        {"to": ranges_definition[i]}
+                        if i == 0
+                        else {
+                            "from": ranges_definition[i - 1],
+                            "to": ranges_definition[i],
+                        }
+                    )
+                    for i in range(len(ranges_definition))
+                ]
+
+                ranges.append({"from": ranges_definition[-1]})
+
+                aggs[name] = {
+                    "range": {
+                        "field": name,
+                        "ranges": ranges,
+                    }
+                }
+
+        elif facet_type == "date_histogram":
+            date_histogram = {"date_histogram": {"field": name}}
+
+            if "order" in facet_definition:
+                date_histogram["date_histogram"]["order"] = {
+                    "_key": facet_definition.get("order", "asc")
+                }
+
+            if "date_format" in facet_definition:
+                date_histogram["date_histogram"]["format"] = facet_definition.get(
+                    "date_format"
+                )
+
+            if "calendar_interval" in facet_definition:
+                date_histogram["date_histogram"]["calendar_interval"] = (
+                    facet_definition.get("calendar_interval")
+                )
+
+            aggs[name] = date_histogram
+
+    return aggs
+
+
+def search(
+    index,
+    from_,
+    size,
+    search_fields=None,
+    query_string=None,
+    filters=None,
+    sort_by=None,
+    suggestion_field_name=None,
+    search_type=es_settings.SEARCH_QUERY_TYPE,
+    search_operator=es_settings.SEARCH_QUERY_OPERATOR,
+    scoring_functions=None,
+):
+    body = get_search_body(
+        from_,
+        size,
+        search_fields=search_fields,
+        query_string=query_string,
+        filters=filters,
+        sort_by=sort_by,
+        suggestion_field_name=suggestion_field_name,
+        search_type=search_type,
+        search_operator=search_operator,
+        scoring_functions=scoring_functions,
+    )
+    return es.search(index=index, body=body)
+
+
+def facet_search(
+    index,
+    from_,
+    size,
+    query_string=None,
+    search_fields=None,
+    default_filters=None,
+    sort_by=None,
+    suggestion_field_name=None,
+    search_type=es_settings.SEARCH_QUERY_TYPE,
+    search_operator=es_settings.SEARCH_QUERY_OPERATOR,
+    scoring_functions=None,
+    facet_filters=None,
+    aggs_definitions=None,
+):
+
+    aggs = get_elasticsearch_aggs(aggs_definitions) if aggs_definitions else {}
+    index_body = {"index": index}
+
+    result_body = get_search_body(
+        from_,
+        size,
+        search_fields=search_fields,
+        query_string=query_string,
+        filters=default_filters + facet_filters,
+        sort_by=sort_by,
+        suggestion_field_name=suggestion_field_name,
+        search_type=search_type,
+        search_operator=search_operator,
+        scoring_functions=scoring_functions,
+        aggs=aggs,
+    )
+
+    unfiltered_body = get_search_body(
+        0,
+        0,
+        search_fields=search_fields,
+        query_string=query_string,
+        filters=default_filters,
+        sort_by=sort_by,
+        suggestion_field_name=suggestion_field_name,
+        search_type=search_type,
+        search_operator=search_operator,
+        scoring_functions=scoring_functions,
+        aggs=aggs,
+    )
+
+    multi_body = [
+        index_body,
+        result_body,
+        index_body,
+        unfiltered_body,
+    ]
+    search_results, unfiltered_result = es.msearch(body=multi_body)["responses"]
+
+    search_result_status = search_results["status"]
+    unfiltered_result_status = unfiltered_result["status"]
+
+    if search_result_status > 200:
+        raise ElasticSearchQueryException(
+            "Something went wrong during elasticsearch query", search_results
+        )
+    elif unfiltered_result_status > 200:
+        raise ElasticSearchQueryException(
+            "Something went wrong during elasticsearch query", unfiltered_result_status
+        )
+
+    return (
+        search_results,
+        unfiltered_result,
+    )
+
+
+class BaseElasticSearchApi(BaseModelIndex):
+    Model = None
+    SEARCH_FIELDS = []
+    SUGGESTION_FIELD_NAME = None
+
+    def get_search_fields(self, search_fields):
+        if search_fields:
+            return search_fields
+
+        return self.SEARCH_FIELDS
+
+    def get_filters(self, filters):
+        if filters is not None:
+            return filters
+
+        return []
+
+    def format_filters(self, filters):
+        return [{"match": {key: value}} for key, value in filters.items()]
+
+    def format_order_by(self, order_by):
+        orderings = []
+        for ordering in filter(None, order_by):
+            if ordering.startswith("-"):
+                orderings.append({ordering[1:]: "desc"})
+            else:
+                orderings.append(ordering)
+        return orderings
+
+    def get_suggestion_field_name(self, suggestion_field_name):
+        if suggestion_field_name is not None:
+            return suggestion_field_name
+
+        return self.SUGGESTION_FIELD_NAME
+
+    def make_queryset(self, search_result):
+        return search_result_to_queryset(search_result, self.get_model())
+
+    def search(
+        self,
+        from_=0,
+        to=es_settings.DEFAULT_ITEMS_PER_PAGE,
+        query_string=None,
+        search_fields=None,
+        filters=None,
+        sort_by=None,
+        suggestion_field_name=None,
+        search_type=es_settings.SEARCH_QUERY_TYPE,
+        search_operator=es_settings.SEARCH_QUERY_OPERATOR,
+        scoring_functions=None,
+    ):
+        search_results = search(
+            self.get_index_name(),
+            from_,
+            to,
+            query_string=query_string,
+            search_fields=self.get_search_fields(search_fields),
+            filters=self.get_filters(filters),
+            sort_by=sort_by,
+            suggestion_field_name=self.get_suggestion_field_name(suggestion_field_name),
+            search_type=search_type,
+            search_operator=search_operator,
+            scoring_functions=scoring_functions,
+        )
+
+        total_hits = search_results["hits"]["total"]["value"]
+        total_relation = search_results["hits"]["total"].get("relation", "")
+
+        if total_relation == "gte":
+            # The total hits is higher than max_result_window, we want to do another query to get the correct total
+            total_hits = do_count(
+                self.get_index_name(),
+                query_string=query_string,
+                search_fields=search_fields,
+                filters=filters,
+                search_type=search_type,
+                search_operator=search_operator,
+            )
+
+        return self.make_queryset(search_results), total_hits
+
+    def facet_search(
+        self,
+        from_=0,
+        to=es_settings.DEFAULT_ITEMS_PER_PAGE,
+        query_string=None,
+        search_fields=None,
+        filters=None,
+        sort_by=None,
+        suggestion_field_name=None,
+        search_type=es_settings.SEARCH_QUERY_TYPE,
+        search_operator=es_settings.SEARCH_QUERY_OPERATOR,
+        scoring_functions=None,
+        facet_filters=None,
+        aggs_definitions=None,
+    ):
+        search_results, unfiltered_result = facet_search(
+            self.get_index_name(),
+            from_,
+            to,
+            query_string=query_string,
+            search_fields=self.get_search_fields(search_fields),
+            facet_filters=facet_filters,
+            sort_by=sort_by,
+            suggestion_field_name=self.get_suggestion_field_name(suggestion_field_name),
+            search_type=search_type,
+            search_operator=search_operator,
+            scoring_functions=scoring_functions,
+            default_filters=self.get_filters(filters),
+            aggs_definitions=aggs_definitions,
+        )
+
+        return (
+            self.make_queryset(search_results),
+            search_results,
+            unfiltered_result,
+        )
+
+    def paginated_search(
+        self,
+        from_=0,
+        to=es_settings.DEFAULT_ITEMS_PER_PAGE,
+        query_string=None,
+        search_fields=None,
+        filters=None,
+        sort_by=None,
+        suggestion_field_name=None,
+        search_type=es_settings.SEARCH_QUERY_TYPE,
+        search_operator=es_settings.SEARCH_QUERY_OPERATOR,
+        scoring_functions=None,
+    ):
+        instances, total_hits = self.search(
+            from_=from_,
+            to=to,
+            query_string=query_string,
+            search_fields=search_fields,
+            filters=filters,
+            sort_by=sort_by,
+            suggestion_field_name=suggestion_field_name,
+            search_type=search_type,
+            search_operator=search_operator,
+            scoring_functions=scoring_functions,
+        )
+
+        return paginate_result(instances, total_hits, to)
+
+    def paginated_facet_search(
+        self,
+        from_=0,
+        to=es_settings.DEFAULT_ITEMS_PER_PAGE,
+        query_string=None,
+        search_fields=None,
+        filters=None,
+        sort_by=None,
+        suggestion_field_name=None,
+        search_type=es_settings.SEARCH_QUERY_TYPE,
+        search_operator=es_settings.SEARCH_QUERY_OPERATOR,
+        scoring_functions=None,
+        facet_filters=None,
+        aggs_definitions=None,
+    ):
+        instances, search_results, unfiltered_result = self.facet_search(
+            from_=from_,
+            to=to,
+            query_string=query_string,
+            search_fields=search_fields,
+            filters=filters,
+            sort_by=sort_by,
+            suggestion_field_name=suggestion_field_name,
+            search_type=search_type,
+            search_operator=search_operator,
+            scoring_functions=scoring_functions,
+            facet_filters=facet_filters,
+            aggs_definitions=aggs_definitions,
+        )
+
+        total_hits = search_results["hits"]["total"]["value"]
+        total_relation = search_results["hits"]["total"].get("relation", "")
+
+        if total_relation == "gte":
+            # The total hits is higher than max_result_window, we want to do another query to get the correct total
+            total_hits = do_count(
+                self.get_index_name(),
+                query_string=query_string,
+                search_fields=search_fields,
+                filters=filters + facet_filters,
+                search_type=search_type,
+                search_operator=search_operator,
+            )
+
+        return (
+            paginate_result(instances, total_hits, to),
+            search_results,
+            unfiltered_result,
+        )
diff --git a/oscar_elasticsearch/search/backend.py b/oscar_elasticsearch/search/backend.py
index 616f0b8..ced0a88 100644
--- a/oscar_elasticsearch/search/backend.py
+++ b/oscar_elasticsearch/search/backend.py
@@ -1,3 +1,8 @@
-from oscar.core.loading import get_class
+from elasticsearch import Elasticsearch
 
-SearchBackend = get_class("search.search", "SearchBackend")
+from oscar_elasticsearch.search.settings import ELASTICSEARCH_SERVER_URLS
+
+es = Elasticsearch(
+    hosts=ELASTICSEARCH_SERVER_URLS,
+    verify_certs=False,
+)
diff --git a/oscar_elasticsearch/search/constants.py b/oscar_elasticsearch/search/constants.py
index 5e8c73d..6353ad6 100644
--- a/oscar_elasticsearch/search/constants.py
+++ b/oscar_elasticsearch/search/constants.py
@@ -3,3 +3,4 @@
 ES_CTX_PUBLIC = "p"
 ES_CTX_AVAILABLE = "a"
 ES_CTX_BROWSABLE = "b"
+ES_CTX_NOT_PUBLIC = "n"
diff --git a/oscar_elasticsearch/search/extra.py b/oscar_elasticsearch/search/extra.py
deleted file mode 100644
index d4f9a1c..0000000
--- a/oscar_elasticsearch/search/extra.py
+++ /dev/null
@@ -1,3 +0,0 @@
-def process_product_fields(search_fields):
-    """Hook for adding any extra fields to the product in case of overrides"""
-    return search_fields
diff --git a/oscar_elasticsearch/search/facets.py b/oscar_elasticsearch/search/facets.py
index 389ff3d..8801a68 100644
--- a/oscar_elasticsearch/search/facets.py
+++ b/oscar_elasticsearch/search/facets.py
@@ -23,17 +23,18 @@ def strip_pagination(url):
     return url.as_string()
 
 
-def process_facets(request_full_path, form, facets):
+def process_facets(request_full_path, form, facets, facet_definitions=None):
     unfiltered_facets, filtered_facets = facets
     selected_multi_facets = form.selected_multi_facets
-    processed_facets = []
-    facet_definitions = settings.FACETS
+    if not facet_definitions:
+        facet_definitions = []
+    processed_facets = {}
 
     for facet_definition in facet_definitions:
         facet_name = facet_definition["name"]
         selected_facets = selected_multi_facets[facet_name]
-        unfiltered_facet = unfiltered_facets.get(facet_name)
-        filtered_facet = filtered_facets.get(facet_name, {})
+        unfiltered_facet = unfiltered_facets["aggregations"].get(facet_name)
+        filtered_facet = filtered_facets["aggregations"].get(facet_name, {})
         if unfiltered_facet is None:
             continue
 
@@ -47,6 +48,15 @@ def process_facets(request_full_path, form, facets):
             ):
                 continue
 
+            # filter out empty buckets
+            if facet_definition.get("type") == "date_histogram":
+                unfiltered_buckets = filter(
+                    lambda bucket: bucket["doc_count"] > 0, unfiltered_buckets
+                )
+                filtered_buckets = filter(
+                    lambda bucket: bucket["doc_count"] > 0, filtered_buckets
+                )
+
             facet = Facet(
                 facet_definition,
                 unfiltered_buckets,
@@ -54,7 +64,7 @@ def process_facets(request_full_path, form, facets):
                 request_full_path,
                 selected_facets,
             )
-            processed_facets.append(facet)
+            processed_facets[facet_name] = facet
 
     return processed_facets
 
diff --git a/oscar_elasticsearch/search/fixtures/catalogue/catalogue.json b/oscar_elasticsearch/search/fixtures/catalogue/catalogue.json
index 9ed4f28..8c8ebf5 100644
--- a/oscar_elasticsearch/search/fixtures/catalogue/catalogue.json
+++ b/oscar_elasticsearch/search/fixtures/catalogue/catalogue.json
@@ -102,6 +102,19 @@
         "slug": "root"
     }
 },
+{
+    "model": "catalogue.category",
+    "pk": 2,
+    "fields": {
+        "path": "0002",
+        "depth": 1,
+        "numchild": 0,
+        "name": "Hoihoi",
+        "description": "<p>Some ambulant hoi's</p>",
+        "image": "",
+        "slug": "hoi"
+    }
+},
 {
     "model": "catalogue.productcategory",
     "pk": 1,
diff --git a/oscar_elasticsearch/search/forms.py b/oscar_elasticsearch/search/forms.py
index ebc1371..989ce88 100644
--- a/oscar_elasticsearch/search/forms.py
+++ b/oscar_elasticsearch/search/forms.py
@@ -44,19 +44,8 @@ class BaseSearchForm(forms.Form):
         widget=forms.HiddenInput(),
     )
 
-    # Search
-    RELEVANCY = "relevancy"
-    NEWEST = "newest"
-    POPULARITY = "popularity"
-
-    SORT_BY_CHOICES = [
-        (RELEVANCY, _("Relevancy")),
-        (POPULARITY, _("Most popular")),
-        (NEWEST, _("Newest")),
-    ]
-
-    # Map query params to sorting fields.
-    SORT_BY_MAP = {NEWEST: "-date_created", POPULARITY: "-popularity"}
+    SORT_BY_CHOICES = settings.SORT_BY_CHOICES_SEARCH
+    SORT_BY_MAP = settings.SORT_BY_MAP_SEARCH
 
     sort_by = forms.ChoiceField(
         label=_("Sort by"), choices=[], widget=forms.Select(), required=False
@@ -104,6 +93,7 @@ def selected_multi_facets(self):
             if ":" not in facet_kv:
                 continue
 
+            # maxsplit on 1 to prevent error when there is another : in the value
             field_name, value = facet_kv.split(":", 1)
             selected_multi_facets[field_name].append(value)
 
@@ -130,36 +120,8 @@ class Media:
 
 
 class CatalogueSearchForm(BaseSearchForm):
-    # Search
-    RELEVANCY = "relevancy"
-    TOP_RATED = "rating"
-    NEWEST = "newest"
-    PRICE_HIGH_TO_LOW = "price-desc"
-    PRICE_LOW_TO_HIGH = "price-asc"
-    TITLE_A_TO_Z = "title-asc"
-    TITLE_Z_TO_A = "title-desc"
-    POPULARITY = "popularity"
-
-    SORT_BY_CHOICES = [
-        (RELEVANCY, _("Relevancy")),
-        (POPULARITY, _("Most popular")),
-        (TOP_RATED, _("Customer rating")),
-        (NEWEST, _("Newest")),
-        (PRICE_HIGH_TO_LOW, _("Price high to low")),
-        (PRICE_LOW_TO_HIGH, _("Price low to high")),
-        (TITLE_A_TO_Z, _("Title A to Z")),
-        (TITLE_Z_TO_A, _("Title Z to A")),
-    ]
-
-    SORT_BY_MAP = {
-        TOP_RATED: "-rating",
-        NEWEST: "-date_created",
-        POPULARITY: "-popularity",
-        PRICE_HIGH_TO_LOW: "-price",
-        PRICE_LOW_TO_HIGH: "price",
-        TITLE_A_TO_Z: "title",
-        TITLE_Z_TO_A: "-title",
-    }
+    SORT_BY_CHOICES = settings.SORT_BY_CHOICES_CATALOGUE
+    SORT_BY_MAP = settings.SORT_BY_MAP_CATALOGUE
 
     category = forms.IntegerField(
         required=False, label=_("Category"), widget=forms.HiddenInput()
@@ -176,7 +138,9 @@ def __init__(self, *args, **kwargs):
     def get_sort_choices(self):
         choices = super().get_sort_choices()
         if feature_hidden("reviews"):
-            return [(key, value) for (key, value) in choices if key != self.TOP_RATED]
+            return [
+                (key, value) for (key, value) in choices if key != settings.TOP_RATED
+            ]
         else:
             return choices
 
diff --git a/oscar_elasticsearch/search/helpers.py b/oscar_elasticsearch/search/helpers.py
index afd49a0..75d4f0f 100644
--- a/oscar_elasticsearch/search/helpers.py
+++ b/oscar_elasticsearch/search/helpers.py
@@ -1,75 +1,48 @@
-from django.db import transaction
-
 from oscar.core.loading import get_model, get_class
 
-from extendedsearch import index
-from extendedsearch.backends import get_search_backend
+from oscar_elasticsearch.search import settings
 
 chunked = get_class("search.utils", "chunked")
-ProductProxy = get_model("search", "ProductProxy")
-CategoryProxy = get_model("search", "CategoryProxy")
-
+Product = get_model("catalogue", "Product")
+Category = get_model("catalogue", "Category")
 
-def add_bulk(instances, model):
-    backend = get_search_backend()
-    for chunk in chunked(instances, 100):
-        backend.add_bulk(model, chunk)
+ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex")
+CategoryElasticsearchIndex = get_class(
+    "search.api.category", "CategoryElasticsearchIndex"
+)
 
 
-def update_index_category(category_id):
-    with transaction.atomic():
-        # make sure all transactions involving this category have finished before
-        # updating the index.
-        categories = CategoryProxy.objects.filter(pk=category_id).select_for_update()
-        try:
-            category = categories.get()
-            index.insert_or_update_object(category)
-        except CategoryProxy.DoesNotExist:
-            pass  # if the category has been deleted, weel so be it
+def update_index_category(category_id, update_products=True):
+    update_index_categories([category_id])
 
-        product_ids = list(
-            ProductProxy.objects.filter(categories__id=category_id).values_list(
+    if update_products:
+        product_ids = set(
+            Product.objects.filter(categories__id=category_id).values_list(
                 "pk", flat=True
             )
         )
-        update_index_products(product_ids)
+        update_index_products(list(product_ids))
 
 
-def update_index_categories(category_ids):
-    with transaction.atomic():
-        # make sure all transactions involving this category have finished before
-        # updating the index.
-        categories = list(CategoryProxy.objects.filter(pk__in=category_ids))
-        add_bulk(categories, CategoryProxy)
+def update_index_categories(category_ids, update_products=True):
+    for chunk in chunked(category_ids, settings.INDEXING_CHUNK_SIZE):
+        categories = Category.objects.filter(id__in=chunk)
+        CategoryElasticsearchIndex().update_or_create(categories)
 
-        product_ids = list(
-            ProductProxy.objects.filter(categories__id__in=category_ids).values_list(
+    if update_products:
+        product_ids = set(
+            Product.objects.filter(categories__id__in=category_ids).values_list(
                 "pk", flat=True
             )
         )
-        update_index_products(product_ids)
+        update_index_products(list(product_ids))
 
 
 def update_index_product(product_id):
-    with transaction.atomic():
-        # make sure all transactions involving this product have finished before
-        # updating the index.
-        products = ProductProxy.objects.filter(pk=product_id).select_for_update()
-        try:
-            product = products.get()
-            index.insert_or_update_object(product)
-        except ProductProxy.DoesNotExist:
-            pass  # if the product has been deleted, well so be it.
+    update_index_products([product_id])
 
 
 def update_index_products(product_ids):
-    for chunk in chunked(product_ids, 100):
-        with transaction.atomic():
-            # make sure all transactions involving this chunk have finished before
-            # updating the index.
-            products = (
-                ProductProxy.get_indexed_objects()
-                .filter(pk__in=chunk)
-                .select_for_update()
-            )
-            add_bulk(products, ProductProxy)
+    for chunk in chunked(product_ids, settings.INDEXING_CHUNK_SIZE):
+        products = Product.objects.filter(id__in=chunk)
+        ProductElasticsearchIndex().update_or_create(products)
diff --git a/oscar_elasticsearch/search/indexing/__init__.py b/oscar_elasticsearch/search/indexing/__init__.py
new file mode 100644
index 0000000..5ef53c5
--- /dev/null
+++ b/oscar_elasticsearch/search/indexing/__init__.py
@@ -0,0 +1,2 @@
+from .indexer import *
+from .settings import *
diff --git a/oscar_elasticsearch/search/indexing/indexer.py b/oscar_elasticsearch/search/indexing/indexer.py
new file mode 100644
index 0000000..76a1c48
--- /dev/null
+++ b/oscar_elasticsearch/search/indexing/indexer.py
@@ -0,0 +1,140 @@
+from contextlib import contextmanager
+
+from django.utils.crypto import get_random_string
+from django.utils.text import format_lazy
+from django.utils.encoding import force_str
+
+from oscar.core.loading import get_class
+
+from elasticsearch.helpers import bulk
+from elasticsearch.exceptions import NotFoundError
+
+from oscar_elasticsearch.search.api.base import BaseModelIndex
+
+es = get_class("search.backend", "es")
+
+
+class Indexer(object):
+    def __init__(self, name, mappings, settings):
+        self.name = name
+        self.alias_name = format_lazy(
+            "{name}_{suffix}", name=name, suffix=get_random_string(7).lower()
+        )
+        self.mappings = mappings
+        self.settings = settings
+
+    def execute(self, documents):
+        self.bulk_index(documents, self.alias_name)
+
+    def start(self):
+        # Create alias
+        self.create(self.alias_name)
+
+    def index(self, _id, document, current_alias=None):
+        if current_alias is None:
+            current_alias = self.get_current_alias()
+
+        _index = force_str(current_alias)
+
+        es.index(index=_index, id=_id, document=document, ignore=[400])
+
+    def bulk_index(self, documents, current_alias=None):
+        if current_alias is None:
+            current_alias = self.get_current_alias()
+
+        _index = force_str(current_alias)
+
+        docs = []
+        for doc in documents:
+            doc["_index"] = _index
+            docs.append(doc)
+
+        bulk(es, docs, ignore=[400])
+
+    def get_current_alias(self):
+        aliasses = list(es.indices.get_alias(name=self.name).keys())
+        if aliasses:
+            return aliasses[0]
+
+        return self.alias_name
+
+    def finish(self):
+        es.indices.refresh(index=self.alias_name)
+
+        # Check if alias exists for indice
+        if es.indices.exists_alias(name=self.name):
+            # Get alisases
+            aliased_indices = es.indices.get_alias(name=self.name).keys()
+
+            # Link the new alias to the old indice
+            es.indices.put_alias(name=self.name, index=self.alias_name)
+
+            # Cleanup old aliased
+            for index in aliased_indices:
+                if index != self.alias_name:
+                    self.delete(index)
+        else:
+            self.delete(self.name)
+
+            # No indices yet, make alias from original name to alias name
+            es.indices.put_alias(name=self.name, index=self.alias_name)
+
+    def create(self, name):
+        return es.indices.create(
+            index=name, body={"settings": self.settings, "mappings": self.mappings}
+        )
+
+    def delete(self, name):
+        try:
+            es.indices.delete(index=name)
+        except NotFoundError:
+            pass
+
+    def delete_doc(self, _id):
+        try:
+            return es.delete(index=self.get_current_alias(), id=_id)
+        except NotFoundError:
+            pass
+
+
+class ESModelIndexer(BaseModelIndex):
+    def __init__(self):
+        super().__init__()
+        self.indexer = Indexer(
+            self.get_index_name(), self.get_index_mapping(), self.get_index_settings()
+        )
+
+    def make_documents(self, objects):
+        raise NotImplementedError(
+            "Please implement `make_documents` on your indexer class"
+        )
+
+    def update_or_create(self, objects):
+        es_data = self.make_documents(objects)
+        return self.indexer.bulk_index(es_data)
+
+    def index(self, obj):
+        (es_data,) = self.make_documents([obj])
+        self.indexer.index(obj.id, es_data["_source"])
+
+    @contextmanager
+    def reindex(self):
+        """
+        Example usage:
+        with CategoryElasticsearchIndex().reindex() as index:
+            for chunk in chunked(categories, settings.INDEXING_CHUNK_SIZE):
+                index.reindex_objects(chunk)
+        """
+        self.indexer.start()
+
+        try:
+            yield self
+        finally:
+            self.indexer.finish()
+
+    def reindex_objects(self, objects):
+        es_data = self.make_documents(objects)
+        return self.indexer.execute(es_data)
+
+    def delete(self, _id):
+        return self.indexer.delete_doc(_id)
diff --git a/oscar_elasticsearch/search/indexing/settings.py b/oscar_elasticsearch/search/indexing/settings.py
new file mode 100644
index 0000000..c52604d
--- /dev/null
+++ b/oscar_elasticsearch/search/indexing/settings.py
@@ -0,0 +1,233 @@
+from oscar_elasticsearch.search.settings import (
+    INDEX_PREFIX,
+    FACETS,
+    AUTOCOMPLETE_CONTEXTS,
+    MAX_GRAM,
+    SEARCH_FIELDS,
+)
+
+
+def get_oscar_index_settings():
+    return {
+        "analysis": {
+            "analyzer": {
+                # the simplest analyzer most useful for normalizing and splitting a sentence into words
+                # this is most likely only used as a search analyzer
+                "lowercasewhitespace": {
+                    "tokenizer": "whitespace",
+                    "filter": ["lowercase", "asciifolding"],
+                    "char_filter": ["non_ascii_character_filter_mapping"],
+                },
+                # this analyzer will keep all punctuation and numbers and make ngrams
+                # as small as a single character. Only usefull for upcs and techincal terms
+                "technical_analyzer": {
+                    "tokenizer": "whitespace",
+                    "filter": [
+                        "shallow_edgengram",
+                        "lowercase",
+                        "asciifolding",
+                        "max_gram_truncate",
+                    ],
+                    "char_filter": ["non_ascii_character_filter_mapping"],
+                },
+                # should be used as the search analyzer for terms analyzed with the
+                # technical_analyzer. Will just split the input into words and normalize
+                # but keeping in mind the max ngram size.
+                "technical_search_analyzer": {
+                    "tokenizer": "whitespace",
+                    "filter": [
+                        "lowercase",
+                        "asciifolding",
+                        "max_gram_truncate",
+                    ],
+                    "char_filter": ["non_ascii_character_filter_mapping"],
+                },
+                # this analyzer is usefull for important textual data like titles,
+                # that contain a lot of search terms.
+                "title_analyzer": {
+                    "tokenizer": "standard",
+                    "filter": [
+                        "edgengram",
+                        "lowercase",
+                        "asciifolding",
+                        "max_gram_truncate",
+                    ],
+                },
+                # should be used as the search analyzer for terms analyzed with title_analyzer
+                "reversed_title_analyzer": {
+                    "tokenizer": "standard",
+                    "filter": [
+                        "lowercase",
+                        "asciifolding",
+                        "reversed_edgengram",
+                        "max_gram_truncate",
+                    ],
+                },
+                # this analyzer is most usefull for long textual data. punctuation and numbers
+                # WILL BE STRIPPED
+                "standard": {
+                    "tokenizer": "standard",
+                    "filter": ["lowercase", "asciifolding"],
+                },
+                # This analyzer is usefull for when you need to find really specific data inside some text,
+                # for example you have a 'Volvo Penta TAD163532E' code inside your model type and you want it to be found with 'Penta D16'
+                # Also use the 'technical_search_analyzer' for this one.
+                "technical_title_analyzer": {
+                    "tokenizer": "whitespace",
+                    "filter": [
+                        "ngram",
+                        "lowercase",
+                        "asciifolding",
+                        "max_gram_truncate",
+                    ],
+                },
+            },
+            "tokenizer": {
+                "ngram_tokenizer": {"type": "ngram", "min_gram": 3, "max_gram": 15},
+                "edgengram_tokenizer": {
+                    "type": "edge_ngram",
+                    "min_gram": 2,
+                    "max_gram": MAX_GRAM,
+                },
+            },
+            "filter": {
+                "ngram": {"type": "ngram", "min_gram": 3, "max_gram": MAX_GRAM},
+                "edgengram": {
+                    "type": "edge_ngram",
+                    "min_gram": 2,
+                    "max_gram": MAX_GRAM,
+                },
+                "shallow_edgengram": {
+                    "type": "edge_ngram",
+                    "min_gram": 1,
+                    "max_gram": MAX_GRAM,
+                },
+                "reversed_edgengram": {
+                    "type": "edge_ngram",
+                    "min_gram": 3,
+                    "max_gram": MAX_GRAM,
+                    "side": "back",
+                },
+                "max_gram_truncate": {"type": "truncate", "length": MAX_GRAM},
+            },
+            "char_filter": {
+                "non_ascii_character_filter_mapping": {
+                    "type": "mapping",
+                    "mappings": ["’ => '"],
+                }
+            },
+        },
+        "index": {"number_of_shards": 1, "max_ngram_diff": MAX_GRAM},
+    }
+
+
+OSCAR_INDEX_MAPPING = {
+    "properties": {
+        "id": {"type": "integer", "store": True},
+        "content_type": {"type": "keyword", "store": True},
+        "title": {
+            "type": "text",
+            "analyzer": "lowercasewhitespace",
+            "fielddata": True,
+            "copy_to": "_all_text",
+            "fields": {"raw": {"type": "keyword"}},
+        },
+        "search_title": {
+            "type": "text",
+            "analyzer": "title_analyzer",
+            "search_analyzer": "standard",
+            "fields": {
+                "reversed": {
+                    "type": "text",
+                    "analyzer": "reversed_title_analyzer",
+                    "search_analyzer": "standard",
+                }
+            },
+        },
+        "is_public": {"type": "boolean"},
+        "code": {"type": "keyword", "copy_to": "_all_text"},
+        "slug": {"type": "keyword", "copy_to": "_all_text"},
+        "description": {
+            "type": "text",
+            "analyzer": "standard",
+            "copy_to": "_all_text",
+        },
+        "absolute_url": {"type": "keyword"},
+        "_all_text": {"type": "text", "analyzer": "standard"},
+    }
+}
+
+
+def get_attributes_to_index():
+    attrs_properties = {}
+
+    for facet in FACETS:
+        if "attrs." in facet["name"]:
+            name = facet["name"].replace("attrs.", "")
+            facet_type = "keyword"
+            if facet["type"] == "range":
+                facet_type = "double"
+
+            attrs_properties[name] = {"type": facet_type, "copy_to": "_all_text"}
+
+    return attrs_properties
+
+
+def get_products_index_mapping():
+    OSCAR_PRODUCTS_INDEX_MAPPING = OSCAR_INDEX_MAPPING.copy()
+
+    OSCAR_PRODUCTS_INDEX_MAPPING["properties"].update(
+        {
+            "upc": {
+                "type": "text",
+                "analyzer": "technical_analyzer",
+                "fielddata": True,
+                "search_analyzer": "technical_search_analyzer",
+            },
+            "parent_id": {"type": "integer"},
+            "product_class": {"type": "keyword"},
+            "structure": {"type": "text", "copy_to": "_all_text"},
+            "rating": {"type": "float"},
+            "priority": {"type": "integer"},
+            "price": {"type": "double"},
+            "num_available": {"type": "integer"},
+            "is_available": {"type": "boolean"},
+            "currency": {"type": "text", "copy_to": "_all_text"},
+            "date_created": {"type": "date"},
+            "date_updated": {"type": "date"},
+            "string_attrs": {"type": "text", "copy_to": "_all_text"},
+            "popularity": {"type": "integer"},
+            "status": {"type": "text"},
+            "categories": {
+                "type": "nested",
+                "properties": {
+                    "id": {"type": "integer"},
+                    "description": {
+                        "type": "text",
+                        "copy_to": "_all_text",
+                    },
+                    "full_name": {
+                        "type": "text",
+                    },
+                },
+            },
+            "attrs": {"type": "object", "properties": get_attributes_to_index()},
+            "suggest": {"type": "completion", "contexts": AUTOCOMPLETE_CONTEXTS},
+        }
+    )
+
+    return OSCAR_PRODUCTS_INDEX_MAPPING
+
+
+def get_categories_index_mapping():
+    OSCAR_CATEGORIES_INDEX_MAPPING = OSCAR_INDEX_MAPPING.copy()
+    OSCAR_CATEGORIES_INDEX_MAPPING.update(
+        {"properties": {"full_name": {"type": "text"}, "full_slug": {"type": "text"}}}
+    )
+    return OSCAR_CATEGORIES_INDEX_MAPPING
+
+
+OSCAR_PRODUCTS_INDEX_NAME = "%s__catalogue_product" % INDEX_PREFIX
+OSCAR_CATEGORIES_INDEX_NAME = "%s__catalogue_category" % INDEX_PREFIX
+OSCAR_PRODUCT_SEARCH_FIELDS = SEARCH_FIELDS + ["upc^2"]
+OSCAR_CATEGORY_SEARCH_FIELDS = SEARCH_FIELDS
diff --git a/oscar_elasticsearch/search/management/commands/update_index_categories.py b/oscar_elasticsearch/search/management/commands/update_index_categories.py
new file mode 100644
index 0000000..67e0894
--- /dev/null
+++ b/oscar_elasticsearch/search/management/commands/update_index_categories.py
@@ -0,0 +1,29 @@
+from django.core.management.base import BaseCommand
+
+from oscar.core.loading import get_class, get_model
+
+from oscar_elasticsearch.search import settings
+
+chunked = get_class("search.utils", "chunked")
+CategoryElasticsearchIndex = get_class(
+    "search.api.category", "CategoryElasticsearchIndex"
+)
+
+Category = get_model("catalogue", "Category")
+
+
+class Command(BaseCommand):
+    def handle(self, *args, **options):
+        categories = Category.objects.browsable()
+
+        with CategoryElasticsearchIndex().reindex() as index:
+            for chunk in chunked(categories, settings.INDEXING_CHUNK_SIZE):
+                index.reindex_objects(chunk)
+                self.stdout.write(".", ending="")
+                self.stdout.flush()  # Ensure the dots are displayed immediately
+
+        self.stdout.write(
+            self.style.SUCCESS(
+                "\n%i categories successfully indexed" % categories.count()
+            )
+        )
diff --git a/oscar_elasticsearch/search/management/commands/update_index_products.py b/oscar_elasticsearch/search/management/commands/update_index_products.py
new file mode 100644
index 0000000..2048802
--- /dev/null
+++ b/oscar_elasticsearch/search/management/commands/update_index_products.py
@@ -0,0 +1,76 @@
+import time
+from django.core.management.base import BaseCommand
+from oscar.core.loading import get_class, get_model
+from oscar_elasticsearch.search import settings
+
+chunked = get_class("search.utils", "chunked")
+ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex")
+Product = get_model("catalogue", "Product")
+
+
+class Command(BaseCommand):
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--debug",
+            action="store_true",
+            help="Run command in debug mode",
+        )
+
+    def handle(self, *args, **options):
+        if options["debug"]:
+            return self.handle_debug()
+
+        products = Product.objects.browsable()
+        products_total = products.count()
+
+        with ProductElasticsearchIndex().reindex() as index:
+            for chunk in chunked(products, settings.INDEXING_CHUNK_SIZE):
+                index.reindex_objects(chunk)
+                self.stdout.write(".", ending="")
+                self.stdout.flush()  # Ensure the dots are displayed immediately
+
+        self.stdout.write(
+            self.style.SUCCESS("\n%i products successfully indexed" % products_total)
+        )
+
+    def handle_debug(self):
+        """
+        Display more detailed information about the indexing process, such as the time it took to index each chunk.
+        This is useful when debugging the performance of the indexing process.
+        """
+        overall_start_time = time.time()
+        products = Product.objects.browsable()
+        products_total = products.count()
+        total_chunks = products_total / settings.INDEXING_CHUNK_SIZE
+        processed_chunks = 0
+
+        with ProductElasticsearchIndex().reindex() as index:
+            for chunk in chunked(products, settings.INDEXING_CHUNK_SIZE):
+                chunk_index_time = time.time()
+                index.reindex_objects(chunk)
+                processed_chunks += 1
+                chunk_duration = time.time() - chunk_index_time
+
+                self.stdout.write(
+                    self.style.SUCCESS(
+                        "Processed chunk %i of %i (%i/%s products indexed) in %.2f seconds"
+                        % (
+                            processed_chunks,
+                            total_chunks,
+                            min(
+                                processed_chunks * settings.INDEXING_CHUNK_SIZE,
+                                products_total,
+                            ),
+                            products_total,
+                            chunk_duration,
+                        )
+                    )
+                )
+
+        total_duration = time.time() - overall_start_time
+        self.stdout.write(
+            self.style.SUCCESS(
+                "\n%i products successfully indexed in %.2f seconds"
+                % (products_total, total_duration)
+            )
+        )
diff --git a/oscar_elasticsearch/search/management/commands/update_oscar_index.py b/oscar_elasticsearch/search/management/commands/update_oscar_index.py
index 216ee2b..11dac70 100644
--- a/oscar_elasticsearch/search/management/commands/update_oscar_index.py
+++ b/oscar_elasticsearch/search/management/commands/update_oscar_index.py
@@ -1,29 +1,8 @@
-# pylint: disable=unused-import
-from extendedsearch.commands import WagtailUpdateIndexCommand
+from django.core.management.base import BaseCommand
+from django.core.management import call_command
 
-DEFAULT_CHUNK_SIZE = 100
 
-
-class Command(WagtailUpdateIndexCommand):
-    def add_arguments(self, parser):
-        parser.add_argument(
-            "--backend",
-            action="store",
-            dest="backend_name",
-            default=None,
-            help="Specify a backend to update",
-        )
-        parser.add_argument(
-            "--schema-only",
-            action="store_true",
-            dest="schema_only",
-            default=False,
-            help="Prevents loading any data into the index",
-        )
-        parser.add_argument(
-            "--chunk_size",
-            dest="chunk_size",
-            type=int,
-            default=DEFAULT_CHUNK_SIZE,
-            help="Set number of records to be fetched at once for inserting into the index",
-        )
+class Command(BaseCommand):
+    def handle(self, *args, **options):
+        call_command("update_index_products")
+        call_command("update_index_categories")
diff --git a/oscar_elasticsearch/search/mappings/__init__.py b/oscar_elasticsearch/search/mappings/__init__.py
new file mode 100644
index 0000000..91da356
--- /dev/null
+++ b/oscar_elasticsearch/search/mappings/__init__.py
@@ -0,0 +1,1 @@
+from .categories import *
diff --git a/oscar_elasticsearch/search/mappings/categories.py b/oscar_elasticsearch/search/mappings/categories.py
new file mode 100644
index 0000000..f2d15cd
--- /dev/null
+++ b/oscar_elasticsearch/search/mappings/categories.py
@@ -0,0 +1,65 @@
+import odin
+
+from django.utils.html import strip_tags
+
+from oscar.core.loading import get_class, get_model
+
+from oscar_odin.resources.catalogue import Category as CategoryResource
+from oscar_odin.mappings._common import OscarBaseMapping
+from oscar_odin.resources._base import OscarResource
+
+OscarElasticSearchResourceMixin = get_class(
+    "search.mappings.mixins", "OscarElasticSearchResourceMixin"
+)
+
+Category = get_model("catalogue", "Category")
+
+
+class CategoryElasticSearchResource(OscarElasticSearchResourceMixin):
+    full_name: str
+    full_slug: str
+
+
+class CategoryMapping(OscarBaseMapping):
+    from_resource = CategoryResource
+    to_resource = CategoryElasticSearchResource
+
+    @odin.assign_field
+    def content_type(self) -> str:
+        return "catalogue.category"
+
+    @odin.map_field(from_field="name", to_field=["title", "search_title"])
+    def title(self, name) -> str:
+        return name, name
+
+    @odin.assign_field
+    def description(self) -> str:
+        return strip_tags(self.source.description)
+
+    @odin.assign_field
+    def code(self) -> str:
+        if self.source.code:
+            return self.source.code
+
+        return "%s-%s" % (self.source.slug, self.source.id)
+
+
+class ElasticSearchResource(OscarResource):
+    _index: str
+    _id: str
+    _source: CategoryElasticSearchResource
+
+
+class CategoryElasticSearchMapping(OscarBaseMapping):
+    from_resource = CategoryResource
+    to_resource = ElasticSearchResource
+
+    mappings = (odin.define(from_field="id", to_field="_id"),)
+
+    @odin.assign_field
+    def _source(self) -> str:
+        return CategoryMapping.apply(self.source)
+
+    @odin.assign_field
+    def _index(self) -> str:
+        return self.context.get("_index")
diff --git a/oscar_elasticsearch/search/mappings/mixins.py b/oscar_elasticsearch/search/mappings/mixins.py
new file mode 100644
index 0000000..7a7e214
--- /dev/null
+++ b/oscar_elasticsearch/search/mappings/mixins.py
@@ -0,0 +1,13 @@
+from oscar_odin.resources._base import OscarResource
+
+
+class OscarElasticSearchResourceMixin(OscarResource):
+    id: str
+    content_type: str
+    title: str
+    is_public: bool
+    code: str
+    description: str
+    absolute_url: str
+    slug: str
+    search_title: str
diff --git a/oscar_elasticsearch/search/mappings/products/__init__.py b/oscar_elasticsearch/search/mappings/products/__init__.py
new file mode 100644
index 0000000..7242990
--- /dev/null
+++ b/oscar_elasticsearch/search/mappings/products/__init__.py
@@ -0,0 +1,39 @@
+import odin
+
+from oscar.core.loading import get_class
+
+from oscar_odin.resources._base import OscarResource
+from oscar_odin.resources.catalogue import (
+    Product as ProductResource,
+    Category as CategoryResource,
+)
+from oscar_odin.mappings._common import OscarBaseMapping
+
+ProductElasticSearchResource = get_class(
+    "search.mappings.products.resources", "ProductElasticSearchResource"
+)
+ProductMapping = get_class("search.mappings.products.mappings", "ProductMapping")
+
+
+class ElasticSearchResource(OscarResource):
+    _index: str
+    _id: str
+    _source: ProductElasticSearchResource
+    _op_type: str = "index"
+
+
+class ProductElasticSearchMapping(OscarBaseMapping):
+    from_resource = ProductResource
+    to_resource = ElasticSearchResource
+
+    register_mapping = False
+
+    mappings = (odin.define(from_field="id", to_field="_id"),)
+
+    @odin.assign_field
+    def _source(self) -> str:
+        return ProductMapping.apply(self.source)
+
+    @odin.assign_field
+    def _index(self) -> str:
+        return self.context.get("_index")
diff --git a/oscar_elasticsearch/search/mappings/products/mappings.py b/oscar_elasticsearch/search/mappings/products/mappings.py
new file mode 100644
index 0000000..dfde0ce
--- /dev/null
+++ b/oscar_elasticsearch/search/mappings/products/mappings.py
@@ -0,0 +1,166 @@
+import odin
+
+from django.utils import timezone
+from django.utils.html import strip_tags
+from django.db.models import QuerySet
+
+from dateutil.relativedelta import relativedelta
+
+from oscar.core.loading import get_model, get_class
+
+from oscar_odin.mappings._common import OscarBaseMapping
+from oscar_odin.resources.catalogue import (
+    Product as ProductResource,
+    Category as CategoryResource,
+)
+
+from oscar_elasticsearch.search.constants import (
+    ES_CTX_PUBLIC,
+    ES_CTX_AVAILABLE,
+    ES_CTX_BROWSABLE,
+)
+from oscar_elasticsearch.search import settings
+
+Product = get_model("catalogue", "Product")
+Line = get_model("order", "Line")
+
+OscarElasticSearchResourceMixin = get_class(
+    "search.mappings.mixins", "OscarElasticSearchResourceMixin"
+)
+get_attributes_to_index = get_class(
+    "search.indexing.settings", "get_attributes_to_index"
+)
+
+CategoryElasticSearchRelatedResource = get_class(
+    "search.mappings.products.resources", "CategoryElasticSearchRelatedResource"
+)
+ProductElasticSearchResource = get_class(
+    "search.mappings.products.resources", "ProductElasticSearchResource"
+)
+
+ATTRIBUTES_TO_INDEX = get_attributes_to_index().keys()
+
+
+class CategoryRelatedMapping(OscarBaseMapping):
+    from_resource = CategoryResource
+    to_resource = CategoryElasticSearchRelatedResource
+
+    mappings = [odin.define(from_field="name", to_field="full_name")]
+
+    @odin.assign_field
+    def description(self) -> str:
+        return strip_tags(self.source.description)
+
+
+class ProductMapping(OscarBaseMapping):
+    from_resource = ProductResource
+    to_resource = ProductElasticSearchResource
+
+    register_mapping = False
+
+    mappings = (
+        odin.define(from_field="upc", to_field="code_auto_complete"),
+        odin.define(from_field="upc", to_field="code"),
+        odin.define(from_field="is_available_to_buy", to_field="is_available"),
+    )
+
+    @odin.map_field(from_field="product_class")
+    def product_class(self, obj):
+        return obj.slug
+
+    @odin.map_field(from_field="priority")
+    def priority(self, priority):
+        if (
+            not self.source.is_available_to_buy
+            and settings.PRIORITIZE_AVAILABLE_PRODUCTS
+        ):
+            return -1
+
+        return priority
+
+    @odin.assign_field
+    def popularity(self):
+        # In our search.api.product make_documents method, we annotate the popularity, this way
+        # we don't have to do N+1 queries to get the popularity of each product.
+        if hasattr(self.source, "model_instance") and hasattr(
+            self.source.model_instance, "popularity"
+        ):
+            return self.source.model_instance.popularity
+
+        # Fallback to n+1 query, though, try to avoid this.
+        months_to_run = settings.MONTHS_TO_RUN_ANALYTICS
+        orders_above_date = timezone.now() - relativedelta(months=months_to_run)
+
+        return Line.objects.filter(
+            product_id=self.source.id, order__date_placed__gte=orders_above_date
+        ).count()
+
+    @odin.assign_field
+    def content_type(self) -> str:
+        return "catalogue.product"
+
+    @odin.assign_field(to_list=True)
+    def categories(self) -> str:
+        return CategoryRelatedMapping.apply(self.source.categories)
+
+    @odin.map_field(from_field="attributes")
+    def attrs(self, attributes):
+        attrs = {}
+        for code in ATTRIBUTES_TO_INDEX:
+            if code in attributes:
+                attribute = attributes[code]
+
+                if isinstance(attribute, QuerySet):
+                    attrs[code] = [str(o) for o in attribute]
+                else:
+                    attrs[code] = str(attribute)
+
+        return attrs
+
+    @odin.assign_field(to_list=True)
+    def status(self):
+        ctx = []
+
+        if not self.source.is_public:
+            return ["n"]
+
+        ctx.append(ES_CTX_PUBLIC)
+
+        # non public items are not available or browsable
+        if self.source.is_available_to_buy:
+            ctx.append(ES_CTX_AVAILABLE)
+
+        # depending on FILTER_AVAILABLE things are browsable only if
+        # they are available
+        is_browsable = (
+            self.source.structure == Product.STANDALONE
+            or self.source.structure == Product.PARENT
+        )
+        if not settings.FILTER_AVAILABLE and is_browsable:
+            ctx.append(ES_CTX_BROWSABLE)
+        elif self.source.is_available_to_buy and is_browsable:
+            ctx.append(ES_CTX_BROWSABLE)
+
+        return ctx
+
+    @odin.assign_field(to_list=True)
+    def string_attrs(self):
+        attrs = [str(a) for a in self.source.attributes.values()]
+        if self.source.structure == Product.PARENT:
+            for child in self.source.children:
+                attrs.append(child.title)
+                attrs.extend([str(a.value_as_text) for a in child.attributes.values()])
+
+        return attrs
+
+    @odin.map_field(
+        from_field=settings.AUTOCOMPLETE_SEARCH_FIELDS, to_field="suggest", to_list=True
+    )
+    def suggest(self, *args):
+        return list(args)
+
+    @odin.map_field(
+        from_field="title", to_field=["title", "search_title", "title_auto_complete"]
+    )
+    def title(self, title):
+        return title, title, title
diff --git a/oscar_elasticsearch/search/mappings/products/resources.py b/oscar_elasticsearch/search/mappings/products/resources.py
new file mode 100644
index 0000000..069fd79
--- /dev/null
+++ b/oscar_elasticsearch/search/mappings/products/resources.py
@@ -0,0 +1,39 @@
+from decimal import Decimal
+
+from typing import Optional, List
+
+from datetime import datetime
+
+from oscar_odin.resources._base import OscarResource
+from oscar_odin.fields import DecimalField
+
+from oscar_elasticsearch.search.mappings.mixins import OscarElasticSearchResourceMixin
+
+
+class CategoryElasticSearchRelatedResource(OscarResource):
+    id: int
+    description: str
+    full_name: str
+
+
+class ProductElasticSearchResource(OscarElasticSearchResourceMixin):
+    upc: str
+    title_auto_complete: str
+    code_auto_complete: str
+    structure: str
+    rating: Optional[float]
+    priority: int
+    parent_id: Optional[int]
+    product_class: Optional[int]
+    price: Decimal = DecimalField()
+    currency: str
+    num_available: int
+    is_available: bool
+    categories: List[CategoryElasticSearchRelatedResource]
+    attrs: dict
+    date_created: datetime
+    date_updated: datetime
+    string_attrs: List[str]
+    popularity: int
+    status: List[str]
+    suggest: List[str]
diff --git a/oscar_elasticsearch/search/migrations/0001_initial.py b/oscar_elasticsearch/search/migrations/0001_initial.py
deleted file mode 100644
index 687fd63..0000000
--- a/oscar_elasticsearch/search/migrations/0001_initial.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# Generated by Django 2.0.5 on 2018-06-11 08:31
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='Synonym',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('synonyms', models.CharField(help_text='Comma-separated list of synonyms, optionally mapped using "=>" to what they should be rewritten to.', max_length=255)),
-            ],
-            options={
-                'verbose_name_plural': 'Synonyms',
-                'verbose_name': 'Synonym',
-                'abstract': False,
-            },
-        ),
-    ]
diff --git a/oscar_elasticsearch/search/migrations/0002_categoryproxy_productproxy.py b/oscar_elasticsearch/search/migrations/0002_categoryproxy_productproxy.py
deleted file mode 100644
index 614bf47..0000000
--- a/oscar_elasticsearch/search/migrations/0002_categoryproxy_productproxy.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# Generated by Django 2.0.8 on 2019-02-07 15:49
-
-from django.db import migrations
-import extendedsearch.index
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('catalogue', '0001_initial'),
-        ('search', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='CategoryProxy',
-            fields=[
-            ],
-            options={
-                'managed': False,
-                'proxy': True,
-            },
-            bases=(extendedsearch.index.Indexed, 'catalogue.category'),
-        ),
-        migrations.CreateModel(
-            name='ProductProxy',
-            fields=[
-            ],
-            options={
-                'verbose_name': 'Product',
-                'verbose_name_plural': 'Products',
-                'managed': False,
-                'proxy': True,
-            },
-            bases=('catalogue.product', extendedsearch.index.Indexed),
-        ),
-    ]
diff --git a/oscar_elasticsearch/search/migrations/0003_delete_synonym.py b/oscar_elasticsearch/search/migrations/0003_delete_synonym.py
deleted file mode 100644
index 8b3f1eb..0000000
--- a/oscar_elasticsearch/search/migrations/0003_delete_synonym.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# Generated by Django 2.2.13 on 2020-09-30 09:08
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('search', '0002_categoryproxy_productproxy'),
-    ]
-
-    operations = [
-        migrations.DeleteModel(
-            name='Synonym',
-        ),
-    ]
diff --git a/oscar_elasticsearch/search/mixins.py b/oscar_elasticsearch/search/mixins.py
deleted file mode 100644
index 3c23f89..0000000
--- a/oscar_elasticsearch/search/mixins.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from extendedsearch import index
-from .constants import APP_LABEL
-
-
-class ProductProxyMixinMeta:
-    """
-    This is so we can extend the search app and add
-    methods to the ProductProxy without replacing the entire app.
-    If the ProductProxyMixin has no _meta attribute the field name can not
-    be determined by wagtail.
-    """
-
-    app_label = APP_LABEL
-    abstract = True
-    swapped = False
-    local_managers = []
-
-
-class ProductProxyMixin(index.Indexed):
-    _meta = ProductProxyMixinMeta()
-
-    def process_attributes(self, attrs):
-        return attrs
-
-    search_fields = []
diff --git a/oscar_elasticsearch/search/models.py b/oscar_elasticsearch/search/models.py
deleted file mode 100644
index e219d42..0000000
--- a/oscar_elasticsearch/search/models.py
+++ /dev/null
@@ -1,228 +0,0 @@
-from django.utils import timezone
-from django.utils.functional import cached_property
-from django.utils.translation import gettext_lazy as _
-
-from dateutil.relativedelta import relativedelta
-
-from oscar.core.loading import is_model_registered, get_model, get_class, get_classes
-
-from extendedsearch import index
-
-from .constants import ES_CTX_PUBLIC, ES_CTX_AVAILABLE, ES_CTX_BROWSABLE
-from . import settings
-
-merge_dicts, es_type_for_product_attribute, product_attributes_es_config = get_classes(
-    "search.utils",
-    ["merge_dicts", "es_type_for_product_attribute", "product_attributes_es_config"],
-)
-process_product_fields = get_class("search.extra", "process_product_fields")
-ProductProxyMixin = get_class("search.mixins", "ProductProxyMixin")
-
-
-__all__ = []
-
-
-if is_model_registered("catalogue", "Product"):
-
-    class ProductProxy(get_model("catalogue", "Product"), ProductProxyMixin):
-        def popularity(self):
-            months_to_run = settings.MONTHS_TO_RUN_ANALYTICS
-            orders_above_date = timezone.now() - relativedelta(months=months_to_run)
-
-            Line = get_model("order", "Line")
-
-            return Line.objects.filter(
-                product=self, order__date_placed__gte=orders_above_date
-            ).count()
-
-        @cached_property
-        def purchase_info(self):
-            selector = get_class("partner.strategy", "Selector")
-            strategy = selector().strategy()
-            if self.is_parent:
-                return strategy.fetch_for_parent(self)
-
-            return strategy.fetch_for_product(self)
-
-        def is_available(self):
-            return self.purchase_info.availability.is_available_to_buy
-
-        def price(self):
-            purchase_info = self.purchase_info
-            if purchase_info.price.exists:
-                return purchase_info.price.incl_tax
-
-            return None
-
-        def status(self):
-            ctx = []
-            if self.is_public:
-                ctx.append(ES_CTX_PUBLIC)
-
-                # non public items are not available or browsable
-                if self.is_available():
-                    ctx.append(ES_CTX_AVAILABLE)
-
-                # depending on FILTER_AVAILABLE things are browsable only if
-                # they are available
-                is_browsable = self.is_standalone or self.is_parent
-                if not settings.FILTER_AVAILABLE and is_browsable:
-                    ctx.append(ES_CTX_BROWSABLE)
-                elif self.is_available() and is_browsable:
-                    ctx.append(ES_CTX_BROWSABLE)
-
-            return ctx
-
-        def string_attrs(self):
-            attrs = [str(a.value_as_text) for a in self.attribute_values.all()]
-            if self.structure == self.PARENT:
-                for child in ProductProxy.objects.filter(parent=self):
-                    attrs.append(child.title)
-                    attrs.extend(child.string_attrs())
-
-            return attrs
-
-        def child_upc(self):
-            upcs = []
-            if self.structure == self.PARENT:
-                for child in ProductProxy.objects.filter(parent=self):
-                    upcs.append(child.upc)
-            return upcs
-
-        def attrs(self):
-            values = self.get_attribute_values().select_related("attribute")
-            result = {}
-            for value in values:
-                try:
-                    at = value.attribute
-                    if at.type == at.OPTION:
-                        result[value.attribute.code] = value.value.option
-                    elif at.type == at.MULTI_OPTION:
-                        result[value.attribute.code] = [a.option for a in value.value]
-                    elif (
-                        es_type_for_product_attribute(at) != "text"
-                    ):  # text can not be indexed properly.
-                        result[value.attribute.code] = value.value
-                except AttributeError:  # an option was empty
-                    pass
-
-            if self.is_parent:
-                for child in ProductProxy.objects.filter(parent=self):
-                    result = merge_dicts(
-                        result,
-                        child.attrs(),
-                        multivalue=True,
-                        deduplicate_iterables=True,
-                    )
-
-            return self.process_attributes(result)
-
-        def object(self):
-            """Mimic a haystack search result"""
-            return self
-
-        def product_class__name(self):
-            return self.get_product_class().name
-
-        def category_id(self):
-            return self.get_categories().values_list("id", flat=True)
-
-        def category_name(self):
-            return list(self.get_categories().values_list("name", flat=True))
-
-        @classmethod
-        def get_indexed_objects(cls):
-            return cls.objects.all()
-
-        @classmethod
-        def get_search_fields(cls):  # hook extra_product_fields for overriding
-            search_fields = super().get_search_fields()
-            return process_product_fields(search_fields)
-
-        @classmethod
-        def get_autocomplete_contexts(cls):
-            return settings.AUTOCOMPLETE_CONTEXTS
-
-        @classmethod
-        def get_facets(cls):
-            return settings.FACETS
-
-        # See oscar_elasticsearch.search.search.boosted_fields
-        # for default boost values
-        search_fields = [
-            index.FilterField("id"),
-            index.FilterField("product_class__name"),
-            index.FilterField("title"),
-            index.SearchField("title", partial_match=True),
-            index.FilterField("is_public"),
-            index.AutocompleteField("title"),
-            index.AutocompleteField("upc", es_extra={"analyzer": "keyword"}),
-            index.FilterField("upc"),
-            index.SearchField("upc", es_extra={"analyzer": "keyword"}),
-            index.SearchField("child_upc", es_extra={"analyzer": "keyword"}),
-            index.AutocompleteField("child_upc", es_extra={"analyzer": "keyword"}),
-            index.SearchField("description", partial_match=True),
-            index.FilterField("popularity"),
-            index.FilterField("price", es_extra={"type": "double"}),
-            index.FilterField("is_available", es_extra={"type": "boolean"}),
-            index.FilterField("category_id"),
-            index.SearchField("category_name", partial_match=True),
-            index.AutocompleteField("category_name"),
-            index.RelatedFields(
-                "categories",
-                [
-                    index.SearchField("description", partial_match=True),
-                    index.SearchField("slug"),
-                    index.SearchField("full_name"),
-                    index.SearchField("get_absolute_url"),
-                ],
-            ),
-            index.RelatedFields(
-                "stockrecords",
-                [
-                    index.FilterField("price_currency"),
-                    index.SearchField("partner_sku"),
-                    index.FilterField("partner"),
-                    index.FilterField("num_in_stock"),
-                ],
-            ),
-            index.FilterField("parent_id"),
-            index.FilterField("structure"),
-            index.FilterField("is_standalone"),
-            index.FilterField("slug"),
-            index.FilterField("rating"),
-            index.FilterField("date_created"),
-            index.FilterField("date_updated"),
-            index.SearchField("string_attrs"),
-            index.FilterField("attrs", es_extra=product_attributes_es_config()),
-        ] + ProductProxyMixin.search_fields
-
-        class Meta:
-            proxy = True
-            verbose_name = _("Product")
-            verbose_name_plural = _("Products")
-            managed = False
-            app_label = "search"
-
-    __all__.append("ProductProxy")
-
-
-if is_model_registered("catalogue", "Category"):
-
-    class CategoryProxy(index.Indexed, get_model("catalogue", "Category")):
-        search_fields = [
-            index.SearchField("name", partial_match=True, boost=2),
-            index.AutocompleteField("name"),
-            index.SearchField("description"),
-            index.SearchField("full_name"),
-            index.FilterField("full_slug"),
-            index.FilterField("slug"),
-            index.FilterField("get_absolute_url"),
-        ]
-
-        class Meta:
-            proxy = True
-            managed = False
-            app_label = "search"
-
-    __all__.append("CategoryProxy")
diff --git a/oscar_elasticsearch/search/search.py b/oscar_elasticsearch/search/search.py
deleted file mode 100644
index 3a962e1..0000000
--- a/oscar_elasticsearch/search/search.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# pylint: disable=unused-import
-# by default get this from wagtail-extendedsearch
-from extendedsearch.backend import SearchBackend
diff --git a/oscar_elasticsearch/search/settings.py b/oscar_elasticsearch/search/settings.py
index 246171c..953feb9 100644
--- a/oscar_elasticsearch/search/settings.py
+++ b/oscar_elasticsearch/search/settings.py
@@ -1,6 +1,8 @@
 # pylint: disable=wildcard-import,unused-wildcard-import
 from django.conf import settings
-from extendedsearch.settings import *
+from django.utils.translation import gettext_lazy as _
+
+# from extendedsearch.settings import *
 from .constants import ES_CTX_AVAILABLE, ES_CTX_PUBLIC
 
 HANDLE_STOCKRECORD_CHANGES = getattr(
@@ -19,12 +21,122 @@
 MONTHS_TO_RUN_ANALYTICS = getattr(
     settings, "OSCAR_ELASTICSEARCH_MONTHS_TO_RUN_ANALYTICS", 3
 )
+FACETS = getattr(settings, "OSCAR_ELASTICSEARCH_FACETS", [])
+
 SUGGESTION_STATUS_FILTER = getattr(
     settings,
     "OSCAR_ELASTICSEARCH_SUGGESTION_STATUS_FILTER",
     ES_CTX_AVAILABLE if FILTER_AVAILABLE else ES_CTX_PUBLIC,
 )
-FACETS = getattr(settings, "OSCAR_ELASTICSEARCH_FACETS", [])
+SUGGESTION_FIELD_NAME = getattr(
+    settings, "OSCAR_ELASTICSEARCH_SUGGESTION_FIELD_NAME", "search_title"
+)
 AUTOCOMPLETE_CONTEXTS = getattr(
-    settings, "OSCAR_ELASTICSEARCH_AUTOCOMPLETE_CONTEXTS", []
+    settings,
+    "OSCAR_ELASTICSEARCH_AUTOCOMPLETE_CONTEXTS",
+    [
+        {
+            "name": "status",
+            "type": "category",
+            "path": "status",
+        }
+    ],
+)
+
+MAX_GRAM = 15
+
+AUTOCOMPLETE_SEARCH_FIELDS = getattr(
+    settings, "OSCAR_ELASTICSEARCH_AUTOCOMPLETE_SEARCH_FIELDS", ["title", "upc"]
+)
+
+SEARCH_FIELDS = getattr(
+    settings,
+    "OSCAR_ELASTICSEARCH_SEARCH_FIELDS",
+    [
+        "_all_text",
+        "code",
+        "search_title^1",
+        "search_title.reversed^0.8",
+    ],
+)
+SEARCH_QUERY_TYPE = getattr(
+    settings, "OSCAR_ELASTICSEARCH_SEARCH_QUERY_TYPE", "most_fields"
+)
+SEARCH_QUERY_OPERATOR = getattr(
+    settings, "OSCAR_ELASTICSEARCH_SEARCH_QUERY_OPERATOR", "or"
+)
+
+NUM_SUGGESTIONS = getattr(settings, "OSCAR_ELASTICSEARCH_NUM_SUGGESTIONS", 20)
+
+ELASTICSEARCH_SERVER_URLS = getattr(
+    settings, "OSCAR_ELASTICSEARCH_SERVER_URLS", ["http://127.0.0.1:9200"]
+)
+
+INDEX_PREFIX = getattr(
+    settings, "OSCAR_ELASTICSEARCH_INDEX_PREFIX", "django-oscar-elasticsearch"
+)
+
+RELEVANCY = "relevancy"
+TOP_RATED = "rating"
+NEWEST = "newest"
+PRICE_HIGH_TO_LOW = "price-desc"
+PRICE_LOW_TO_HIGH = "price-asc"
+TITLE_A_TO_Z = "title-asc"
+TITLE_Z_TO_A = "title-desc"
+POPULARITY = "popularity"
+
+
+SORT_BY_CHOICES_SEARCH = getattr(
+    settings,
+    "OSCAR_ELASTICSEARCH_SORT_BY_CHOICES_SEARCH",
+    [
+        (RELEVANCY, _("Relevancy")),
+        (POPULARITY, _("Most popular")),
+        (NEWEST, _("Newest")),
+    ],
+)
+
+SORT_BY_MAP_SEARCH = getattr(
+    settings,
+    "OSCAR_ELASTICSEARCH_SORT_BY_MAP_SEARCH",
+    {NEWEST: "-date_created", POPULARITY: "-popularity"},
+)
+
+SORT_BY_CHOICES_CATALOGUE = getattr(
+    settings,
+    "OSCAR_ELASTICSEARCH_SORT_BY_CHOICES_CATALOGUE",
+    [
+        (RELEVANCY, _("Relevancy")),
+        (POPULARITY, _("Most popular")),
+        (TOP_RATED, _("Customer rating")),
+        (NEWEST, _("Newest")),
+        (PRICE_HIGH_TO_LOW, _("Price high to low")),
+        (PRICE_LOW_TO_HIGH, _("Price low to high")),
+        (TITLE_A_TO_Z, _("Title A to Z")),
+        (TITLE_Z_TO_A, _("Title Z to A")),
+    ],
+)
+
+SORT_BY_MAP_CATALOGUE = getattr(
+    settings,
+    "OSCAR_ELASTICSEARCH_SORT_BY_MAP_CATALOGUE",
+    {
+        TOP_RATED: "-rating",
+        NEWEST: "-date_created",
+        POPULARITY: "-popularity",
+        PRICE_HIGH_TO_LOW: "-price",
+        PRICE_LOW_TO_HIGH: "price",
+        TITLE_A_TO_Z: "title",
+        TITLE_Z_TO_A: "-title",
+    },
+)
+
+DEFAULT_ORDERING = getattr(settings, "OSCAR_ELASTICSEARCH_DEFAULT_ORDERING", None)
+
+FACET_BUCKET_SIZE = getattr(settings, "OSCAR_ELASTICSEARCH_FACET_BUCKET_SIZE", 10)
+
+INDEXING_CHUNK_SIZE = getattr(settings, "OSCAR_ELASTICSEARCH_INDEXING_CHUNK_SIZE", 400)
+
+PRIORITIZE_AVAILABLE_PRODUCTS = getattr(
+    settings, "OSCAR_ELASTICSEARCH_PRIORITIZE_AVAILABLE_PRODUCTS", True
 )
diff --git a/oscar_elasticsearch/search/signal_handlers.py b/oscar_elasticsearch/search/signal_handlers.py
index 962234e..099e4a1 100644
--- a/oscar_elasticsearch/search/signal_handlers.py
+++ b/oscar_elasticsearch/search/signal_handlers.py
@@ -4,15 +4,13 @@
 from django.core.signals import request_finished
 from django.db.models.signals import post_delete, post_save, m2m_changed
 
-from extendedsearch.signal_handlers import post_delete_signal_handler
-
 from . import settings
 
 Product = get_model("catalogue", "Product")
 Category = get_model("catalogue", "Category")
-ProductProxy = get_model("search", "ProductProxy")
 StockRecord = get_model("partner", "StockRecord")
 UpdateIndex = get_class("search.update", "UpdateIndex")
+ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex")
 
 update_index = UpdateIndex()
 
@@ -24,18 +22,26 @@ def push_product_update(instance):
         update_index.push_product(str(instance.parent_id))
 
 
-def product_post_save_signal_handler(sender, instance, raw, **kwargs):
-    if not raw:  # raw is when fixture is loaded
-        push_product_update(instance)
+def product_post_save_signal_handler(sender, instance, **kwargs):
+    if kwargs.get("raw", False):
+        return
+
+    push_product_update(instance)
 
 
 def product_post_delete_signal_handler(sender, instance, **kwargs):
-    return post_delete_signal_handler(ProductProxy(pk=instance.pk), **kwargs)
+    if kwargs.get("raw", False):
+        return
+
+    ProductElasticsearchIndex().delete(instance.pk)
 
 
 def product_category_m2m_changed_signal_handler(
     sender, instance, action, reverse, **kwargs
 ):
+    if kwargs.get("raw", False):
+        return
+
     if action.startswith("post"):
         if reverse:
             update_index.push_category(str(instance.pk))
@@ -43,17 +49,24 @@ def product_category_m2m_changed_signal_handler(
             push_product_update(instance)
 
 
-def category_change_handler(sender, instance, raw=False, **kwargs):
-    if not raw:  # raw is when fixture is loaded
-        update_index.push_category(str(instance.pk))
+def category_change_handler(sender, instance, **kwargs):
+    if kwargs.get("raw", False):
+        return
 
+    update_index.push_category(str(instance.pk))
 
-def stockrecord_change_handler(sender, instance, raw=False, **kwargs):
-    if not raw:  # raw is when fixture is loaded
-        push_product_update(instance.product)
+
+def stockrecord_change_handler(sender, instance, **kwargs):
+    if kwargs.get("raw", False):
+        return
+
+    push_product_update(instance.product)
 
 
 def stockrecord_post_delete_handler(sender, instance, **kwargs):
+    if kwargs.get("raw", False):
+        return
+
     push_product_update(instance.product)
 
 
diff --git a/oscar_elasticsearch/search/utils.py b/oscar_elasticsearch/search/utils.py
index 6e22495..f5c0638 100644
--- a/oscar_elasticsearch/search/utils.py
+++ b/oscar_elasticsearch/search/utils.py
@@ -1,68 +1,9 @@
-from django.db import models
-from django.utils.functional import lazy
+import itertools
 
-from oscar.core.loading import get_model
+from django.db.models import Case, When, QuerySet
 
-# pylint: disable=unused-import
-from extendedsearch.utils import merge_dicts, unique_everseen
 
-from . import settings
-
-ProductAttribute = get_model("catalogue", "ProductAttribute")
-
-
-def _product_attributes_es_config():
-    props = {}
-    for attribute in ProductAttribute.objects.exclude(
-        models.Q(code="") | models.Q(code__isnull=True)
-    ):
-        es_type = es_type_for_product_attribute(attribute)
-        code = attribute.code
-        if es_type != "text":
-            if code in props:
-                if (
-                    props[code]["type"] != es_type
-                ):  # use keyword if different types are detected
-                    props[code] = {"type": "keyword"}
-            else:
-                props[code] = {"type": es_type}
-
-    return {"type": "object", "properties": props}
-
-
-product_attributes_es_config = lazy(_product_attributes_es_config, dict)
-
-
-def es_type_for_product_attribute(attribute):
-    if attribute.type == attribute.TEXT:
-        return "keyword"
-    elif attribute.type == attribute.RICHTEXT:
-        return "text"
-    elif attribute.type == attribute.DATE:
-        return "date"
-    elif attribute.type == attribute.DATETIME:
-        return "date"
-    elif attribute.type == attribute.BOOLEAN:
-        return "boolean"
-    elif attribute.type == attribute.INTEGER:
-        return "integer"
-    elif attribute.type == attribute.INTEGER:
-        return "integer"
-    elif attribute.type == attribute.FLOAT:
-        return "float"
-    elif attribute.type == attribute.OPTION:
-        return "keyword"
-    elif attribute.type == attribute.MULTI_OPTION:
-        return "keyword"
-    else:
-        return "text"
-
-
-def get_facet_names():
-    return [facet["name"] for facet in settings.FACETS]
-
-
-def chunked(qs, size, startindex=0):
+def chunked(iterable, size, startindex=0):
     """
     Divide an interable into chunks of ``size``
 
@@ -71,17 +12,26 @@ def chunked(qs, size, startindex=0):
     >>> list(chunked([1,2,3,4,5,6,7], 3))
     [[1, 2, 3], [4, 5, 6], [7]]
     """
-    while True:
-        chunk = qs[startindex : startindex + size]
-        chunklen = len(chunk)
-        if chunklen:
+    if isinstance(iterable, QuerySet):
+        iterator = iterable.iterator(chunk_size=size)
+        while True:
+            chunk = list(itertools.islice(iterator, size))
+            if not chunk:
+                break
             yield chunk
-        if chunklen < size:
-            break
-        startindex += size
+    else:
+        while True:
+            chunk = iterable[startindex : startindex + size]
+            chunklen = len(chunk)
+            if chunklen:
+                yield chunk
+            if chunklen < size:
+                break
+            startindex += size
+
 
+def search_result_to_queryset(search_results, Model):
+    instance_ids = [hit["_source"]["id"] for hit in search_results["hits"]["hits"]]
 
-class LegacyOscarFacetList(list):
-    def items(self):
-        for item in self:
-            yield None, item
+    preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(instance_ids)])
+    return Model.objects.filter(pk__in=instance_ids).order_by(preserved)
diff --git a/oscar_elasticsearch/search/views/__init__.py b/oscar_elasticsearch/search/views/__init__.py
index 5ebb363..e69de29 100644
--- a/oscar_elasticsearch/search/views/__init__.py
+++ b/oscar_elasticsearch/search/views/__init__.py
@@ -1,3 +0,0 @@
-from .base import BaseSearchView
-from .catalogue import ProductCategoryView
-from .search import CatalogueAutoCompleteView
diff --git a/oscar_elasticsearch/search/views/base.py b/oscar_elasticsearch/search/views/base.py
index f1e0d1f..ecc9e0c 100644
--- a/oscar_elasticsearch/search/views/base.py
+++ b/oscar_elasticsearch/search/views/base.py
@@ -1,161 +1,162 @@
-import logging
+from decimal import Decimal as D
 
-from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
-from django.utils.translation import gettext
 from django.views.generic.list import ListView
+from django.utils.translation import gettext
 
-from oscar.core.loading import get_model, get_class
-
-from extendedsearch.backends import get_search_backend
-from extendedsearch.utils import separate_filters_from_query
-from extendedsearch.query import MatchAll
-
-from elasticsearch.exceptions import RequestError
+from oscar.core.loading import get_class, get_model
 
 from oscar_elasticsearch.search import settings
-from oscar_elasticsearch.search.utils import LegacyOscarFacetList
+from oscar_elasticsearch.search.facets import process_facets
+from oscar_elasticsearch.search.signals import query_hit
 
-logger = logging.getLogger(__name__)
+OSCAR_PRODUCTS_INDEX_NAME = get_class(
+    "search.indexing.settings", "OSCAR_PRODUCTS_INDEX_NAME"
+)
+select_suggestion = get_class("search.suggestions", "select_suggestion")
+es = get_class("search.backend", "es")
+ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex")
 
-SearchBackend = get_search_backend()
+Product = get_model("catalogue", "Product")
 
-ProductProxy = get_model("search", "ProductProxy")
 
-CatalogueSearchForm = get_class("search.forms", "CatalogueSearchForm")
-process_facets = get_class("search.facets", "process_facets")
-get_facet_names = get_class("search.utils", "get_facet_names")
-select_suggestion = get_class("search.suggestions", "select_suggestion")
-query_hit = get_class("search.signals", "query_hit")
+product_search_api = ProductElasticsearchIndex()
 
 
 class BaseSearchView(ListView):
-    model = ProductProxy
+    model = Product
     paginate_by = settings.DEFAULT_ITEMS_PER_PAGE
-
-    order_by_relevance = True
-    facets = get_facet_names()
-    suggestion_field_name = "title"
-
     form_class = None
-
-    def build_form(self, **kwargs):
-        if not self.form_class:
-            raise NotImplementedError(
-                "Please include a form_class when using this view"
-            )
-
-        kwargs["selected_facets"] = self.request.GET.getlist("selected_facets")
-        # pylint: disable=not-callable
-        return self.form_class(self.request.GET, **kwargs)
-
-    def get_base_search_results(self, query, queryset, order_by_relevance):
-        results = SearchBackend.search(
-            query, queryset, order_by_relevance=order_by_relevance
-        )
+    aggs_definitions = settings.FACETS
+    scoring_functions = [
+        {
+            "field_value_factor": {
+                "field": "priority",
+                "modifier": "ln2p",
+                "factor": 1,
+                "missing": 0,
+            },
+        },
+    ]
+
+    def get_aggs_definitions(self):
+        return self.aggs_definitions
+
+    def get_scoring_functions(self):
+        return self.scoring_functions if self.scoring_functions else None
+
+    def get_default_filters(self):
+        filters = [{"term": {"is_public": True}}]
 
         if settings.FILTER_AVAILABLE:
-            results = results.es_filter(is_available=True)
+            filters.append({"term": {"is_available": True}})
+
+        return filters
+
+    def get_facet_filters(self):
+        filters = []
+
+        for name, value in self.form.selected_multi_facets.items():
+            # pylint: disable=W0640
+            definition = list(
+                filter(lambda x: x["name"] == name, self.get_aggs_definitions())
+            )[0]
+            if definition["type"] == "range":
+                ranges = []
+                for val in value:
+                    if val.startswith("*-"):
+                        ranges.append(
+                            {"range": {name: {"to": D(val.replace("*-", ""))}}}
+                        )
+                    elif val.endswith("-*"):
+                        ranges.append(
+                            {"range": {name: {"from": D(val.replace("-*", ""))}}}
+                        )
+                    else:
+                        from_, to = val.split("-")
+                        ranges.append(
+                            {"range": {name: {"from": D(from_), "to": D(to)}}}
+                        )
+
+                filters.append({"bool": {"should": ranges}})
+            else:
+                filters.append({"terms": {name: value}})
+
+        return filters
+
+    def get_sort_by(self):
+        sort_by = []
+        ordering = self.form.get_sort_params(self.form.cleaned_data)
 
-        return results
+        if not ordering and not self.request.GET.get("q"):
+            ordering = settings.DEFAULT_ORDERING
 
-    def get_es_ordering(self):
-        ordering = self.form.get_sort_params(self.form.cleaned_data)
         if ordering:
-            return [ordering]
-        return []
+            if ordering.startswith("-"):
+                sort_by.insert(0, {"%s" % ordering.replace("-", ""): {"order": "desc"}})
+            else:
+                sort_by.insert(0, {"%s" % ordering: {"order": "asc"}})
 
-    def get_query(self):
-        filters, query_string = separate_filters_from_query(
-            self.form.cleaned_data.get("q")
-        )
-        if not query_string or query_string == "*":
-            return filters, MatchAll()
         else:
-            query_hit.send(sender=self, querystring=query_string)
+            sort_by.append("_score")
 
-        return filters, query_string
-
-    def get_queryset(self):
-        return self.model.objects.browsable()
-
-    def get_results(self, order_by_relevance=True):
-        """
-        Fetches the results via the form.
-        """
-        if not self.form.is_valid():
-            logger.error("Invalid form %s", self.form.errors)
-            return self.get_base_search_results(
-                MatchAll(),
-                self.get_queryset().filter(
-                    pk__isnull=True
-                ),  # queryset.none() can not be handled by the elasticsearch querycompiler
-                order_by_relevance=order_by_relevance,
-            )
-
-        filters, query = self.get_query()
-        filters.update(self.form.selected_multi_facets)
+        return sort_by
 
-        return (
-            self.get_base_search_results(
-                query, self.get_queryset(), order_by_relevance=order_by_relevance
-            )
-            .es_filter(**filters)
-            .es_order_by(self.get_es_ordering())
+    def get_form(self, request):
+        # pylint: disable=E1102
+        return self.form_class(
+            data=request.GET or {},
+            selected_facets=request.GET.getlist("selected_facets", []),
         )
 
-    def paginate(self, search_results):
-        page = self.request.GET.get("page", 1)
-        paginator = Paginator(
-            search_results,
-            self.form.cleaned_data.get("items_per_page", self.paginate_by),
-        )
+    def get_context_data(self, *args, **kwargs):
+        context = super().get_context_data(*args, **kwargs)
 
-        try:
-            page_obj = paginator.page(page)
-        except PageNotAnInteger:
-            page_obj = paginator.page(1)
-        except EmptyPage:
-            page_obj = paginator.page(paginator.num_pages)
+        # pylint: disable=W0201
+        self.form = self.get_form(self.request)
+        self.form.is_valid()
 
-        return page_obj, paginator
+        elasticsearch_from = (
+            int(self.request.GET.get("page", 1)) * self.paginate_by
+        ) - self.paginate_by
 
-    def get_context_data(self, *args, **kwargs):
-        context = super().get_context_data(*args, **kwargs)
+        query_string = self.request.GET.get("q", "")
+        if query_string:
+            query_hit.send(sender=self, querystring=query_string)
+
+        paginator, search_results, unfiltered_result = (
+            product_search_api.paginated_facet_search(
+                from_=elasticsearch_from,
+                query_string=query_string,
+                filters=self.get_default_filters(),
+                sort_by=self.get_sort_by(),
+                scoring_functions=self.get_scoring_functions(),
+                facet_filters=self.get_facet_filters(),
+                aggs_definitions=self.get_aggs_definitions(),
+            )
+        )
 
-        self.form = self.build_form()  # pylint: disable=attribute-defined-outside-init
-
-        search_results = self.get_results(order_by_relevance=self.order_by_relevance)
-
-        try:
-            if search_results.count():  # there are actual search results
-                page_obj, paginator = self.paginate(search_results)
-                facets = page_obj.object_list.facets(*self.facets)
-                processed_facets = process_facets(
-                    self.request.get_full_path(), self.form, facets
-                )
-                suggestion = None
-            else:  # no results, fetch suggestions
-                page_obj, paginator = self.paginate(self.get_queryset().none())
-                facets = search_results.facets(*self.facets)
-                processed_facets = process_facets(
-                    self.request.get_full_path(), self.form, facets
-                )
-                suggestions = search_results.suggestions(self.suggestion_field_name)
-                suggestion = select_suggestion(self.suggestion_field_name, suggestions)
-        except RequestError as e:
-            logger.exception(e)
-            page_obj, paginator = self.paginate(self.get_queryset().none())
-            facets = []
-            processed_facets = []
-            suggestion = None
+        if "aggregations" in unfiltered_result:
+            processed_facets = process_facets(
+                self.request.get_full_path(),
+                self.form,
+                (unfiltered_result, search_results),
+                facet_definitions=self.get_aggs_definitions(),
+            )
+        else:
+            processed_facets = None
 
         context["paginator"] = paginator
+        page_obj = paginator.get_page(self.request.GET.get("page", 1))
         context["page_obj"] = page_obj
-        context["suggestion"] = suggestion
+        context["suggestion"] = select_suggestion(
+            product_search_api.get_suggestion_field_name(None),
+            search_results.get("suggest", []),
+        )
         context["page"] = page_obj
         context[self.context_object_name] = page_obj
-        context["facet_data"] = LegacyOscarFacetList(processed_facets)
+        context["facet_data"] = processed_facets
         context["has_facets"] = bool(processed_facets)
-        context["query"] = self.form.cleaned_data.get("q") or gettext("Blank")
+        context["query"] = self.request.GET.get("q") or gettext("Blank")
         context["form"] = self.form
+
         return context
diff --git a/oscar_elasticsearch/search/views/catalogue.py b/oscar_elasticsearch/search/views/catalogue.py
index a2de8ea..fc8d7e0 100644
--- a/oscar_elasticsearch/search/views/catalogue.py
+++ b/oscar_elasticsearch/search/views/catalogue.py
@@ -1,13 +1,24 @@
-from oscar.core.loading import get_class
 from oscar.apps.search.views.catalogue import (
     ProductCategoryView as BaseProductCategoryView,
 )
 
-BaseSearchView = get_class("search.views.base", "BaseSearchView")
 
+class ProductCategoryView(BaseProductCategoryView):
+    def get_default_filters(self):
+        filters = super().get_default_filters()
 
-class ProductCategoryView(BaseProductCategoryView, BaseSearchView):
-    def get_queryset(self):
-        qs = super().get_queryset()
-        categories = self.category.get_descendants_and_self()
-        return qs.filter(categories__in=categories)
+        category_ids = self.category.get_descendants_and_self().values_list(
+            "pk", flat=True
+        )
+
+        filters.append(
+            {
+                "nested": {
+                    "path": "categories",
+                    "query": {"terms": {"categories.id": list(category_ids)}},
+                    "inner_hits": {"size": 0},
+                }
+            }
+        )
+
+        return filters
diff --git a/oscar_elasticsearch/search/views/search.py b/oscar_elasticsearch/search/views/search.py
index 0dc3f35..1ef1d90 100644
--- a/oscar_elasticsearch/search/views/search.py
+++ b/oscar_elasticsearch/search/views/search.py
@@ -2,39 +2,33 @@
 from django.views import View
 from django.http import JsonResponse
 
-from oscar.core.loading import get_model, get_class
+from oscar.core.loading import get_class
 
-from extendedsearch.backends import get_search_backend
+from oscar_elasticsearch.search.indexing.settings import OSCAR_PRODUCTS_INDEX_NAME
+from oscar_elasticsearch.search.settings import (
+    SUGGESTION_STATUS_FILTER,
+)
 
-SearchBackend = get_search_backend()
-
-Category = get_model("catalogue", "Category")
-ProductProxy = get_model("search", "ProductProxy")
-
-unique_everseen = get_class("search.utils", "unique_everseen")
+es = get_class("search.backend", "es")
+autocomplete_suggestions = get_class(
+    "search.api.autocomplete", "autocomplete_suggestions"
+)
 
 
 class CatalogueAutoCompleteView(View):
-    model = ProductProxy
-    search_fields = ["title", "upc", "category_name"]
-
-    def get_queryset(self):
-        return self.model.objects.browsable()
-
-    def _search_suggestions(self):
-        return SearchBackend.search_suggestions(
-            self.request.GET.get("q"), self.get_queryset(), self.search_fields
-        )
-
-    def _get_suggestions(self):
-        results = self._search_suggestions()
-        for _, suggestions in results.items():
-            for suggestion in suggestions:
-                for opt in suggestion["options"]:
-                    yield opt["text"]
+    def get_suggestion_context(self):
+        return {"status": SUGGESTION_STATUS_FILTER}
 
     def get_suggestions(self):
-        return list(unique_everseen(self._get_suggestions()))
+        search_string = self.request.GET.get("q", "")
+
+        return autocomplete_suggestions(
+            OSCAR_PRODUCTS_INDEX_NAME,
+            search_string,
+            "suggest",
+            skip_duplicates=True,
+            contexts=self.get_suggestion_context(),
+        )
 
     # pylint: disable=W0613
     def get(self, request, *args, **kwargs):
diff --git a/sandbox/settings.py b/sandbox/settings.py
index 92b4168..d2cd5bd 100755
--- a/sandbox/settings.py
+++ b/sandbox/settings.py
@@ -26,7 +26,6 @@
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
-
     'django.contrib.sites',
     'django.contrib.flatpages',
 
@@ -67,6 +66,7 @@
     'sorl.thumbnail',
     'django_tables2',
     'oscar_elasticsearch.search.apps.OscarElasticSearchConfig',
+    "oscar_odin"
 ]
 
 MIDDLEWARE = [
@@ -206,9 +206,9 @@
 # Static files (CSS, JavaScript, Images)
 # https://docs.djangoproject.com/en/2.0/howto/static-files/
 
-STATIC_URL = '/static/'
+STATIC_URL = 'static/'
 
-MEDIA_URL = '/media/'
+MEDIA_URL = 'media/'
 
 STATIC_ROOT = os.path.join(BASE_DIR, 'sandbox', 'static')
 
@@ -239,8 +239,12 @@
     {
         "name": "attrs.gewicht",
         "label": "Gewicht",
-        "type": "term",
-        "ranges": []
+        "type": "range",
+        "ranges": [
+            50,
+            100,
+            150
+        ]
     },
     {
         "name": "attrs.googleshopping",
@@ -252,7 +256,8 @@
         "name": "attrs.size",
         "label": "Maat",
         "type": "term",
-        "ranges": []
+        "ranges": [],
+        "order": "asc"
     },
     {
         "name": "attrs.height",
@@ -281,6 +286,11 @@
 ]
 
 OSCAR_DEFAULT_CURRENCY = 'EUR'
+OSCAR_ELASTICSEARCH_PROJECT_NAME = "oscar_elasticsearch"
+
+OSCAR_ELASTICSEARCH_FILTER_AVAILABLE = False
+
+OSCAR_ELASTICSEARCH_SERVER_URLS = [os.environ.get("OSCAR_ELASTICSEARCH_SERVER_URLS", "http://127.0.0.1:9200")]
 
 WAGTAILSEARCH_BACKENDS = {
     "default": {
diff --git a/sandbox/urls.py b/sandbox/urls.py
index 27224b4..5aa1aa9 100755
--- a/sandbox/urls.py
+++ b/sandbox/urls.py
@@ -15,3 +15,4 @@
 
 if settings.DEBUG:
     urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+    urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
diff --git a/setup.py b/setup.py
index 32a86b4..cadb7f9 100644
--- a/setup.py
+++ b/setup.py
@@ -1,7 +1,7 @@
 from setuptools import setup, find_packages
 
 
-__version__ = "2.0.14"
+__version__ = "2.1.4"
 
 
 setup(
@@ -25,22 +25,21 @@
     zip_safe=False,
     # specify dependencies
     install_requires=[
-        "django>=1.11",
+        "django>=3.2",
         "setuptools",
-        "django-oscar>=1.6",
-        "wagtail>=2,<3",
+        "django-oscar>=3.2.5a2",
         "purl",
-        "elasticsearch>=6.0.0,<7.0.0",
+        "elasticsearch>=8.0.0",
         "uwsgidecorators-fallback",
-        "wagtail-extendedsearch",
+        "django-oscar-odin>=0.1.0b4",
     ],
     # mark test target to require extras.
     extras_require={
         "test": [
             "mock",
-            "coverage>=5.4,<5.5",
-            "sorl-thumbnail>=12.9,<12.10",
-            "django-oscar @ git+https://github.com/django-oscar/django-oscar.git",
+            "coverage>=5.4",
+            "sorl-thumbnail>=12.10.0,<13.0.0",
+            "vdt.versionplugin.wheel",
         ],
         "dev": ["pylint>=2.17.4", "pylint-django>=2.5.3", "black>=23.3.0"],
     },


Test patch:
diff --git a/oscar_elasticsearch/search/tests.py b/oscar_elasticsearch/search/tests.py
index d44ec13..68deb01 100644
--- a/oscar_elasticsearch/search/tests.py
+++ b/oscar_elasticsearch/search/tests.py
@@ -1,4 +1,5 @@
 import doctest
+from unittest.mock import patch
 
 from time import sleep
 from django.core.management import call_command
@@ -6,12 +7,21 @@
 from django.urls import reverse
 
 from oscar.core.loading import get_class, get_model
+from oscar.test.factories import ProductFactory
 
 import oscar_elasticsearch.search.format
 import oscar_elasticsearch.search.utils
 
 Product = get_model("catalogue", "Product")
+Category = get_model("catalogue", "Category")
+
 update_index_products = get_class("search.helpers", "update_index_products")
+update_index_categories = get_class("search.helpers", "update_index_categories")
+
+ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex")
+CategoryElasticsearchIndex = get_class(
+    "search.api.category", "CategoryElasticsearchIndex"
+)
 
 
 def load_tests(loader, tests, ignore):  # pylint: disable=W0613
@@ -78,3 +88,141 @@ def test_no_stockrecord_and_not_public(self):
         self.assertContains(response, "second")
         self.assertContains(response, "serious product")
         self.assertNotContains(response, "Hubble Photo")
+
+
+class TestSearchApi(TestCase):
+    fixtures = [
+        "search/auth",
+        "catalogue/catalogue",
+    ]
+
+    @classmethod
+    def setUpClass(cls):
+        # clear search index
+        call_command("update_oscar_index")
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        update_index_products(Product.objects.values_list("id", flat=True))
+        update_index_categories(Category.objects.values_list("id", flat=True))
+        sleep(3)
+
+    product_search_api = ProductElasticsearchIndex()
+    category_search_api = CategoryElasticsearchIndex()
+
+    def test_product_search(self):
+        results, total_hits = self.product_search_api.search()
+
+        self.assertEqual(results.count(), 4)
+        self.assertEqual(total_hits, 4)
+
+        results, total_hits = self.product_search_api.search(query_string="bikini")
+
+        self.assertEqual(results.count(), 1)
+        self.assertEqual(total_hits, 1)
+
+    def test_category_search(self):
+        results, total_hits = self.category_search_api.search()
+
+        self.assertEqual(results.count(), 2)
+        self.assertEqual(total_hits, 2)
+
+        results, total_hits = self.category_search_api.search(query_string="hoi")
+
+        self.assertEqual(results.count(), 1)
+        self.assertEqual(total_hits, 1)
+
+
+class ManagementCommandsTestCase(TestCase):
+    fixtures = [
+        "search/auth",
+        "catalogue/catalogue",
+    ]
+
+    def setUp(self):
+        # Clear index before each test
+        with ProductElasticsearchIndex().reindex() as index:
+            self.product_index = index
+            index.reindex_objects(Product.objects.none())
+
+        with CategoryElasticsearchIndex().reindex() as index:
+            self.category_index = index
+            index.reindex_objects([])
+
+        super().setUp()
+
+    def test_update_index_products(self):
+        results, total_hits = self.product_index.search()
+        self.assertEqual(results.count(), 0)
+        self.assertEqual(total_hits, 0)
+
+        call_command("update_index_products")
+        sleep(3)
+
+        results, total_hits = self.product_index.search()
+        self.assertEqual(results.count(), 4)
+        self.assertEqual(total_hits, 4)
+
+    @patch("oscar_elasticsearch.search.settings.INDEXING_CHUNK_SIZE", 2)
+    def test_update_index_products_multiple_chunks(self):
+        results, total_hits = self.product_index.search()
+        self.assertEqual(results.count(), 0)
+        self.assertEqual(total_hits, 0)
+
+        call_command("update_index_products")
+        sleep(3)
+
+        results, total_hits = self.product_index.search()
+        self.assertEqual(results.count(), 4)
+        self.assertEqual(total_hits, 4)
+
+    def test_update_index_categories(self):
+        results, total_hits = self.category_index.search()
+        self.assertEqual(results.count(), 0)
+        self.assertEqual(total_hits, 0)
+
+        call_command("update_index_categories")
+        sleep(3)
+
+        results, total_hits = self.category_index.search()
+        self.assertEqual(results.count(), 2)
+        self.assertEqual(total_hits, 2)
+
+    @patch("oscar_elasticsearch.search.settings.INDEXING_CHUNK_SIZE", 1)
+    def test_update_index_categories_multiple_chunks(self):
+        results, total_hits = self.category_index.search()
+        self.assertEqual(results.count(), 0)
+        self.assertEqual(total_hits, 0)
+
+        call_command("update_index_categories")
+        sleep(3)
+
+        results, total_hits = self.category_index.search()
+        self.assertEqual(results.count(), 2)
+        self.assertEqual(total_hits, 2)
+
+    @patch("oscar_elasticsearch.search.settings.INDEXING_CHUNK_SIZE", 1000)
+    def test_update_index_products_num_queries(self):
+        def create_parent_child_products():
+            for _ in range(10):
+                parent = ProductFactory(
+                    structure="parent",
+                    stockrecords=[],
+                    categories=Category.objects.all(),
+                )
+                for _ in range(5):
+                    ProductFactory(structure="child", parent=parent, categories=[])
+
+        create_parent_child_products()
+        self.assertEqual(Product.objects.count(), 64)  # 4 inside the fixtures
+
+        with self.assertNumQueries(19):
+            call_command("update_index_products")
+
+        # create 10 extra product with each 5 childs
+        create_parent_child_products()
+
+        # The amount of queries should not change.
+        with self.assertNumQueries(19):
+            call_command("update_index_products")