djangosolrdjango-haystackdjango-oscar

Need advice about a proper way how to make a price range filter (price slider)


Oscar has a such structure of facet configuration:

OSCAR_SEARCH_FACETS = {
    'fields': {
        'rating': {
            'name': _('Rating'),
            'field': 'rating',
            'options': {'sort': 'index'}
        },
        'vendor': {
            'name': _('Vendor'),
            'field': 'vendor',
        },
    }

    'queries': {
        'price_range': {
            'name': _('Price range'),
            'field': 'price',
            'queries': [
                (_('0 to 1000'), u'[0 TO 1000]'),
                (_('1000 to 2000'), u'[1000 TO 2000]'),
                (_('2000 to 4000'), u'[2000 TO 4000]'),
                (_('4000+'), u'[4000 TO *]'),
            ]
        },
    }
}

queries are 'static' and I want to make it a dynamic dependant on a price of products inside a categories.

Based on the OSCAR_SEARCH_FACETS, Oscar using the next code

# oscar/apps/search/search_handlers.py
class SearchHandler(object)::

    # some other methods

    def get_search_context_data(self, context_object_name=None):

        # all comments are removed. See source link above.

        munger = self.get_facet_munger()
        facet_data = munger.facet_data()
        has_facets = any([data['results'] for data in facet_data.values()])

        context = {
            'facet_data': facet_data,
            'has_facets': has_facets,
            'selected_facets': self.request_data.getlist('selected_facets'),
            'form': self.search_form,
            'paginator': self.paginator,
            'page_obj': self.page,
        }

        if context_object_name is not None:
            context[context_object_name] = self.get_paginated_objects()

        return context

generates the next context:

{'facet_data': {
    'rating': {
        'name': 'Рейтинг', 
        'results': [{'name': '5', 'count': 1, 'show_count': True, 'disabled': False, 'selected': False, 'select_url': '/catalogue/category/hardware/cpu_2/?selected_facets=rating_exact%3A5'}]}, 

    'vendor': {
        'name': 'Vendor', 
        'results': [
            {'name': 'AMD', 'count': 103, 'show_count': True, 'disabled': False, 'selected': False, 'select_url': '/catalogue/category/hardware/cpu_2/?selected_facets=vendor_exact%3AAMD'}, 
            {'name': 'INTEL', 'count': 119, 'show_count': True, 'disabled': False, 'selected': False, 'select_url': '/catalogue/category/hardware/cpu_2/?selected_facets=vendor_exact%3AINTEL'}]}, 

    'price_range': {
        'name': 'Price Range',
        'results': [
            {'name': 'from 0 to 1000', 'count': 14, 'show_count': True, 'disabled': False, 'selected': False, 'select_url': '/catalogue/category/hardware/cpu_2/?selected_facets=price_exact%3A%5B0+TO+1000%5D'}, 
            {'name': 'from 1000 to 20000', 'count': 55, 'show_count': True, 'disabled': False, 'selected': False, 'select_url': '/catalogue/category/hardware/cpu_2/?selected_facets=price_exact%3A%5B1000+TO+2000%5D'},
            {'name': 'from 2000 to 4000', 'count': 66, 'show_count': True, 'disabled': False, 'selected': False, 'select_url': '/catalogue/category/hardware/cpu_2/?selected_facets=price_exact%3A%5B2000+TO+4000%5D'}, 
            {'name': 'более 4000', 'count': 89, 'show_count': True, 'disabled': False, 'selected': False, 'select_url': '/catalogue/category/hardware/cpu_2/?selected_facets=price_exact%3A%5B4000+TO+%2A%5D'}]}, 

'has_facets': True, 'selected_facets': [], 'form': <BrowseCategoryForm bound=True, valid=True, fields=(q;sort_by)>, 'paginator': <django.core.paginator.Paginator object at 0x7f4c904c4d68>, 'page_obj': <Page 10 of 10>}}

I can replace generated price_range data, like this:

facet_data['price_range']['results'] = [dict(min_price=SOME_MIN_PRICE, max_price=SOME_MAX_PRICE)]

where I know how to get SOME_MIN_PRICE and SOME_MAX_PRICE, but here I have a problem with url, which filters a product -> I can not find a way, how I can generate a working url for this dynamic facet.

For example, if I change range manually in a browser (for example in the query ?selected_facets=price_exact%3A%5B0+TO+1000%5D I change 1000 to 1001), Oscar returns all products of category where I am.

Could anyone advise me the solution with url and if overall there is a better approach, indicate the direction?


Solution

  • First of all I want to say that this method is quite dirty, especially in that part where it is necessary to prepare URL in js in order to apply price range. If someone knows or has a desire to implement workable URLs via Oscar\Haystack code - welcome.

    Little Note: I do not know if it is designed by Oscar or the previous dev of my current project decided this, but my models have the next structure

    from oscar.apps.catalogue.abstract_models import AbstractProduct
    
    class Product(AbstractProduct):
        short_description = models.TextField(_('Short description'), blank=True)
    
        def get_build_absolute_url(self):
            ...
    
        def cache_delete(self, computers):
            ...
    
        def save(self, *args, **kwargs):
            ...
    
        class CPU(Product):
            class Meta:
                verbose_name = _('Processor')
                verbose_name_plural = _('Processors')
    
    
        class Cooler(Product):
            class Meta:
                verbose_name = _('Cooler')
                verbose_name_plural = _('Coolers')
    
        etc...
    

    In my case I have front-end catalogue with categories which relates to models, ie one Django Model, for example CPU Model has one front-end product category just with CPUs. No mix of different types of products in one category. Based on this models structure it was tricky to find out in which category a client is, because the self.categories[0].product_set.first() from search_handlers.py below returns instance of Product, which is not suitable, because I need instance of CPU, Cooler and so on in order to define min\max price of a category where a client is.


    LETS START

    Read comments inside code for details.

    Somewhere (probably base.html) drop:

    <script type="text/JavaScript" src="{%  static 'your_project/js/credit.min_0s.js' %}"></script>
    <script src="https://code.jquery.com/jquery-1.12.4.js"></script>
    <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
    

    How should OSCAR_SEARCH_FACETS looks like:

    OSCAR_SEARCH_FACETS = {
        'fields': OrderedDict([
            ....
        ]),
    
        # WHAT WE NEED HERE: 'queries' -> 'price_range'
        'queries': OrderedDict([
            ('price_range',
             {
                 'name': _('Price range'),
                 'field': 'price',
                 'queries': [
                     (_('0 to *'), u'[0 TO *]') # Content of this does not matter
                 ]
             }),
        ]),
    
        ....
    
        # For my possible future needs I added the line below which currently produce ['price_exact']
        # If you do not need it, replace everywhere in the code "settings.OSCAR_SEARCH_FACETS['dynamic_queries_field_names']" to ['price_exact']
        # If you want to have just str 'price_exact' (no list), doublecheck JS code "if (dynamic_query_fields.indexOf(k) >= 0)"
        'dynamic_queries_field_names': [field + '_exact' for field in ('price', )]
    }
    

    Create \search\search_handlers.py and \search\forms.py in order to override Oscar files. Where to create? If you do not know, than possibly inside of your 'project' folder, ie. next to your 'some_app' folder.

    In search_handlers.py add:

    import json
    
    from django.conf import settings
    from haystack.query import SearchQuerySet
    from oscar.core.loading import get_model
    from oscar.apps.search.search_handlers import *
    
    
    class SearchHandler(SearchHandler):
    
        def get_search_context_data(self, context_object_name=None):
            """
            Return metadata about the search in a dictionary useful to populate
            template contexts. If you pass in a context_object_name, the dictionary
            will also contain the actual list of found objects.
            The expected usage is to call this function in your view's
            get_context_data:
                search_context = self.search_handler.get_search_context_data(
                    self.context_object_name)
                context.update(search_context)
                return context
            """
    
            # Use the FacetMunger to convert Haystack's awkward facet data into
            # something the templates can use.
            # Note that the FacetMunger accesses object_list (unpaginated results),
            # whereas we use the paginated search results to populate the context
            # with products
            munger = self.get_facet_munger()
    
            facet_data = munger.facet_data()
    
            has_facets = any([data['results'] for data in facet_data.values()])
    
            # ADDED PART
            # self.results sometimes returns category min\max price and sometimes according to filter min\max price, so
            # the behaviour is not stable
            # price_stats = self.results.stats('price').stats_results()['price']
            # So, stable approach:
            # Get a first product from Front-End category, i.e Hardware -> CPUs
            product_id_from_current_category = self.categories[0].product_set.first().pk
    
            from catalogue.models import Product  # needs to populate vars()['Product']. Do not move to top - will not work.
            child_models = [cls.__name__ for cls in vars()['Product'].__subclasses__()]
    
            for model_name in child_models:
                ChildModel = get_model('catalogue', model_name)
                if ChildModel.objects.filter(pk=product_id_from_current_category).exists():
                    break
    
            price_stats = SearchQuerySet().models(ChildModel).stats('price').stats_results()['price']
            min_category_price, max_category_price = round(price_stats['min']), round(price_stats['max'])
    
            dynamic_query_fields = json.dumps(settings.OSCAR_SEARCH_FACETS['dynamic_queries_field_names'])
    
            facet_data['price_range']['results'] = dict(min_category_price=min_category_price,
                                                        max_category_price=max_category_price,
                                                        dynamic_query_fields=dynamic_query_fields)
            # END
    
            context = {
                'facet_data': facet_data,
                'has_facets': has_facets,
                # This is a serious code smell; we just pass through the selected
                # facets data to the view again, and the template adds those
                # as fields to the form. This hack ensures that facets stay
                # selected when changing relevancy.
                'selected_facets': self.request_data.getlist('selected_facets'),
                'form': self.search_form,
                'paginator': self.paginator,
                'page_obj': self.page,
            }
    
            # It's a pretty common pattern to want the actual results in the
            # context, so pass them in if context_object_name is set.
            if context_object_name is not None:
                context[context_object_name] = self.get_paginated_objects()
    
            return context
    

    In forms.py:

    from collections import defaultdict
    
    from django import forms
    from django.conf import settings
    from django.utils.translation import ugettext_lazy as _
    from haystack.forms import FacetedSearchForm
    
    from oscar.apps.search.forms import SearchInput
    from oscar.core.loading import get_class
    
    is_solr_supported = get_class('search.features', 'is_solr_supported')
    
    
    # Build a dict of valid queries
    VALID_FACET_QUERIES = defaultdict(list)
    for facet in settings.OSCAR_SEARCH_FACETS['queries'].values():
        field_name = "%s_exact" % facet['field']
        queries = [t[1] for t in facet['queries']]
        VALID_FACET_QUERIES[field_name].extend(queries)
    
    
    class SearchForm(FacetedSearchForm):
        """
        In Haystack, the search form is used for interpreting
        and sub-filtering the SQS.
        """
        # Use a tabindex of 1 so that users can hit tab on any page and it will
        # focus on the search widget.
        q = forms.CharField(
            required=False, label=_('Search'),
            widget=SearchInput({
                "placeholder": _('Search'),
                "tabindex": "1",
                "class": "form-control"
            }))
    
        # 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"
    
        SORT_BY_CHOICES = [
            (PRICE_LOW_TO_HIGH, _("Price low to high")),
            (PRICE_HIGH_TO_LOW, _("Price high to low")),
            (NEWEST, _("Newest")),
            (TOP_RATED, _("Customer rating")),
        ]
    
        # Map query params to sorting fields.  Note relevancy isn't included here
        # as we assume results are returned in relevancy order in the absence of an
        # explicit sort field being passed to the search backend.
        SORT_BY_MAP = {
            TOP_RATED: '-rating',
            NEWEST: '-date_created',
            PRICE_HIGH_TO_LOW: '-price',
            PRICE_LOW_TO_HIGH: 'price',
            TITLE_A_TO_Z: 'title_s',
            TITLE_Z_TO_A: '-title_s',
        }
        # Non Solr backends don't support dynamic fields so we just sort on title
        if not is_solr_supported():
            SORT_BY_MAP[TITLE_A_TO_Z] = 'title'
            SORT_BY_MAP[TITLE_Z_TO_A] = '-title'
    
        sort_by = forms.ChoiceField(
            label=_("Sort by"), choices=SORT_BY_CHOICES,
            widget=forms.Select(), required=False)
    
        # Implementation of Price range filter based on:
        # https://github.com/django-oscar/django-oscar/blob/master/src/oscar/apps/search/forms.py#L86
        @property
        def selected_multi_facets(self):
            """
            Validate and return the selected facets
            """
            # Process selected facets into a dict(field->[*values]) to handle
            # multi-faceting
            selected_multi_facets = defaultdict(list)
    
            for facet_kv in self.selected_facets:
                if ":" not in facet_kv:
                    continue
                field_name, value = facet_kv.split(':', 1)
    
                # EDITED PART comparing to original Oscar source
                # Validate query facets as they as passed unescaped to Solr
                if field_name in VALID_FACET_QUERIES:
                    if field_name in settings.OSCAR_SEARCH_FACETS['dynamic_queries_field_names']:
                        pass
    
                    else:
                        if value not in VALID_FACET_QUERIES[field_name]:
                            # Invalid query value
                            continue
                # END
    
                selected_multi_facets[field_name].append(value)
    
            return selected_multi_facets
    

    static/js/price_range_filter.js looks like:

    $(document).ready(function() {
        // Next vars are included in price_range_filter.html, as we need to provide data from that template to this js.
        // var min_category_price = Number("{{ facet_data.price_range.results.min_category_price }}".replace(/\s/g,'')),
        //     max_category_price = Number("{{ facet_data.price_range.results.max_category_price }}".replace(/\s/g,'')),
        //     dynamic_query_fields = JSON.parse("{{ facet_data.price_range.results.dynamic_query_fields|escapejs }}"),
        //     current_url = "{{ request.get_full_path }}";
    
        var category_url = current_url.split('/?selected_facets')[0],
            min_filtered_price = 0,
            max_filtered_price = 0;
    
        // 1. Extracts queries (as key:value) from URL
        // 2. Applies price range to Input Fields and Slider
        // 3. Rebuilds 'submit' URL of price range
        function handleUrl(use_globals_filtered_prices) {
    
            // https://stackoverflow.com/a/21152762/4992248
            var qd = {},
                base_url_part = 'selected_facets=',
                rebuilt_url ='?';
    
            if (location.search) location.search.substr(1).split("&").forEach(function(item) {
                var s = item.split("="),
                    k = s[0],
                    v = s[1] && decodeURIComponent(s[1]); //  null-coalescing / short-circuit
                //(k in qd) ? qd[k].push(v) : qd[k] = [v]
                (qd[k] = qd[k] || []).push(v) // null-coalescing / short-circuit
            });
            // End of StackOverflow
    
            var facets = qd['selected_facets'],
                price_changed = false;
    
            for (var i in facets) {
                var kv = facets[i],
                    k = kv.split(':')[0],  // price_exact
                    v = kv.split(':')[1];  // [8732+TO+54432]
    
    
                // Get filtered price range from URL and set Input Fields and Slider according to this range
                // If k in dynamic_query_fields
                if (dynamic_query_fields.indexOf(k) >= 0) {
    
                    // Replace existing price range in URL. Used when price range is changed
                    if (use_globals_filtered_prices){
                        kv = k + ':' + '[' + min_filtered_price + '+TO+' + max_filtered_price + ']';
                        price_changed = true;
                    }
    
                    // Just get min\max_filtered_prices and apply to Input Fields and Slider. Used when page is load
                    else {
                        min_filtered_price = v.substring(v.lastIndexOf("[")+1, v.lastIndexOf("+TO"));
                        max_filtered_price = v.substring(v.lastIndexOf("+TO+")+4, v.lastIndexOf("]"));
    
                        $('input.sliderValue[data-index="0"]').val(min_filtered_price);
                        $('input.sliderValue[data-index="1"]').val(max_filtered_price);
    
                        // 0 and 1 are field indexes
                        $("#slider").slider("values", 0, min_filtered_price);
                        $("#slider").slider("values", 1, max_filtered_price);
                    }
                }
    
                rebuilt_url += base_url_part + kv + '&';
            }
    
            // When we set price range at the first time, i.e when there is no previous version of price range facet.
            if (use_globals_filtered_prices && !price_changed) {
                kv = base_url_part + 'price_exact' + ':' + '[' + min_filtered_price + '+TO+' + max_filtered_price + ']';
                rebuilt_url += kv;
            }
    
            if (rebuilt_url.slice(-1) === '&') {
                rebuilt_url = rebuilt_url.slice(0, -1);
            }
    
            // If facets not selected
            if (rebuilt_url !== '?') {
                var full_url = category_url + encodeURI(rebuilt_url).replace(/:\s*/g, "%3A");
                $("#submit_price").attr("href", full_url);
            }
        }
    
        // SLIDER
        $("#slider").slider({
            min: min_category_price,
            max: max_category_price,
            step: 100,
            range: true,
            values: [min_category_price, max_category_price],
    
            // After sliders are moved, change Input Field Values
            slide: function(event, ui) {
                for (var i = 0; i < ui.values.length; ++i) {
                    $("input.sliderValue[data-index=" + i + "]").val(ui.values[i]);
    
                    if (i === 0){
                        min_filtered_price = ui.values[i];
                    }
                    else {
                        max_filtered_price = ui.values[i]
                    }
    
                    handleUrl(true);
                }
            }
        });
    
        // INPUT FIELDS
        $("input.sliderValue").change(function() {
            var $this = $(this),
                changed_field = $this.data("index"),
                changed_price = $this.val();
    
            $("#slider").slider("values", changed_field, changed_price);
    
        if (changed_field === 0){
            min_filtered_price = changed_price;
    
            //Fix "0" max range URL price when just min range is changed
            if (max_filtered_price === 0){
                max_filtered_price = max_category_price;
            }
    
        }
        else {
            //Fix "0" min range URL price when just max range is changed
            if (min_filtered_price === 0){
                min_filtered_price = min_category_price;
            }
    
            max_filtered_price = changed_price;
        }
    
        handleUrl(true);
        });
    
        // # Executes once the page is loaded
        handleUrl(false);
    
    });
    

    The facets template which extends category template (where client sees products) and which includes html code of price range filter:

    {% extends "catalogue/category.html" %}
    {% block category_facets %}
    
        {% if facet_data.price_range.results %}
            {% include 'search/partials/price_range_filter.html' %}
        {% endif %}
    
        {% with facet_data.vendor as data %}
            {% if data.results %}
                {% include 'search/partials/facet.html' with name=data.name items=data.results %}
            {% endif %}
        {% endwith %}
    
    
        {# OTHET FACETS #}
    
    {% endblock %}
    

    Create root/templates/search/partials/price_range_filter.html. This looks like Oscar's structure, but does not override anything because Oscar does not have such as price_range_filter.html. I decided to drop price_range_filter.html here because Oscar in general is responsible for filters.

    price_range_filter.html looks like (Put styles into css, if you wish :) ):

    {% load staticfiles %}
    
    <dl>
        <dt class="nav-header">{{ facet_data.price_range.name }}</dt>
    
        <div style="display: flex;">
            <input type="text" class="sliderValue" data-index="0"
                   value="{{ facet_data.price_range.results.min_category_price }}"
                   style="width: 70px; margin-right: 10px"/>
    
            <input type="text" class="sliderValue" data-index="1"
                   value="{{ facet_data.price_range.results.max_category_price }}"
                   style="width: 70px; margin-right: 10px"/>
            <a id="submit_price" href="" class="btn btn-default">OK</a>
        </div>
        <br />
        <div id="slider"></div>
    </dl>
    
    {% block extrascripts %}
        <script>
            var min_category_price = Number("{{ facet_data.price_range.results.min_category_price }}".replace(/\s/g,'')),
                max_category_price = Number("{{ facet_data.price_range.results.max_category_price }}".replace(/\s/g,'')),
                dynamic_query_fields = JSON.parse("{{ facet_data.price_range.results.dynamic_query_fields|escapejs }}"),
                current_url = "{{ request.get_full_path }}";
        </script>
    
        <script type="text/JavaScript" src="{%  static 'js/price_range_filter.js' %}"></script>
    {% endblock %}
    

    I am not a 'pro' coder, so any advices\improvements are welcome :)

    Bonus:

    django oscar price range filter