需要有关如何制作价格范围过滤器(价格滑块)的正确方法的建议

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

Oscar有这样的facet配置结构:

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 是 'static',我想使其动态依赖于类别内产品的价格。

基于 OSCAR_SEARCH_FACETS,Oscar 使用 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

生成下一个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>}}

我可以替换生成的 price_range 数据,如下所示:

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

我知道如何获得 SOME_MIN_PRICESOME_MAX_PRICE,但我在 url 上遇到问题,它过滤产品 -> 我找不到方法,如何我可以为这个动态方面生成一个有效的 url。

例如,如果我在浏览器中手动更改范围(例如在查询 ?selected_facets=price_exact%3A%5B0+TO+1000%5D 中我将 1000 更改为 1001),Oscar returns 我所在类别的所有产品。

谁能告诉我url的解决方案,如果总体上有更好的方法,请指出方向?

首先我想说这个方法很脏,特别是在js中需要准备URL才能申请价格范围的部分。如果有人知道或希望通过 Oscar\Haystack 代码实现可行的 URLs - 欢迎。

小记:不知道是Oscar设计的还是我现在项目的前任dev决定的,我的模型有下一个结构

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...

在我的例子中,我有一个前端目录,其中包含与模型相关的类别,即一个 Django 模型,例如 CPU 模型有一个前端产品类别,仅包含 CPUs。不得在一个类别中混用不同类型的产品。 基于这个模型结构,很难找出客户属于哪个类别,因为 search_handlers.py 下面 returns Product 实例的 self.categories[0].product_set.first() 不合适,因为我需要实例CPU、Cooler 等,以定义客户所在类别的 min\max 价格。


开始吧

阅读代码内的注释了解详情。

某处(大概base.html)掉落:

<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>

OSCAR_SEARCH_FACETS 应该是什么样子:

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', )]
}

创建 \search\search_handlers.py\search\forms.py 以覆盖 Oscar 文件。在哪里创建?如果您不知道,那么可能在您的 'project' 文件夹中,即。在您的 'some_app' 文件夹旁边。

search_handlers.py中添加:

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

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 看起来像:

$(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) {

        // 
        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 Whosebug

        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);

});

facets template 扩展了 category template(客户看到产品的地方)并包含 price range filter 的 html 代码:

{% 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 %}

创建root/templates/search/partials/price_range_filter.html。这看起来像 Oscar 的结构,但不会覆盖任何内容,因为 Oscar 没有 price_range_filter.html。我决定在这里删除 price_range_filter.html 因为 Oscar 通常负责过滤器。

price_range_filter.html 看起来像(将样式放入 css,如果你愿意的话:)):

{% 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 %}

我不是 'pro' 程序员,所以欢迎 advices\improvements :)

奖金: