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