# -*- coding: utf-8 -*-
# This file is part of Shuup.
#
# Copyright (c) 2012-2021, Shuup Commerce Inc. All rights reserved.
#
# This source code is licensed under the OSL-3.0 license found in the
# LICENSE file in the root directory of this source tree.
import decimal
import six
from collections import defaultdict
from django import forms
from django.db.models import Q
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.utils.text import capfirst, slugify
from django.utils.translation import get_language, ugettext_lazy as _
from itertools import chain
from shuup import configuration as shuup_config
from shuup.admin.forms.fields import Select2MultipleField
from shuup.core.models import (
Attribute,
AttributeType,
Category,
Manufacturer,
ProductVariationVariable,
ShopProduct,
ShopProductVisibility,
)
from shuup.core.utils import context_cache
from shuup.front.utils.sorts_and_filters import (
ProductListFormModifier,
_get_category_configuration_key,
get_configuration,
get_form_field_label,
)
from shuup.utils.i18n import format_money
[docs]class CommaSeparatedListField(forms.CharField):
[docs] def to_python(self, value):
if isinstance(value, (list, tuple)) and len(value) == 1:
value = value[0].split(",")
else:
value = super(CommaSeparatedListField, self).to_python(value)
return value
[docs] def prepare_value(self, value):
if isinstance(value, (list, tuple)) and len(value) == 1:
value = value[0].split(",")
return value
[docs]class SimpleProductListModifier(ProductListFormModifier):
is_active_key = ""
is_active_label = ""
ordering_key = ""
ordering_label = ""
[docs] def should_use(self, configuration):
if not configuration:
return
return bool(configuration.get(self.is_active_key))
[docs] def get_ordering(self, configuration):
if not configuration:
return 1
return configuration.get(self.ordering_key, 1)
[docs] def get_admin_fields(self):
return [
(self.is_active_key, forms.BooleanField(label=self.is_active_label, required=False)),
(self.ordering_key, forms.IntegerField(label=self.ordering_label, initial=1, required=False)),
]
[docs]class SortProductListByName(SimpleProductListModifier):
is_active_key = "sort_products_by_name"
is_active_label = _("Sort products by name")
ordering_key = "sort_products_by_name_ordering"
ordering_label = _("Ordering for sort by name")
[docs] def get_fields(self, request, category=None):
return [
(
"sort",
forms.CharField(required=False, widget=forms.Select(), label=get_form_field_label("sort", _("Sort"))),
)
]
[docs] def get_choices_for_fields(self):
return [
(
"sort",
[
("name_a", get_form_field_label("name_a", _("Name - A-Z"))),
("name_d", get_form_field_label("name_d", _("Name - Z-A"))),
],
),
]
[docs] def sort_products(self, request, products, data):
sort = data.get("sort", "name_a")
def _get_product_name_lowered_stripped(product):
return product.name.lower().strip()
if not sort:
sort = ""
key = sort[:-2] if sort.endswith(("_a", "_d")) else sort
if key == "name":
sorter = _get_product_name_lowered_stripped
reverse = bool(sort.endswith("_d"))
products = sorted(products, key=sorter, reverse=reverse)
return products
[docs] def get_admin_fields(self):
default_fields = super(SortProductListByName, self).get_admin_fields()
default_fields[0][1].help_text = _("Enable this to allow products to be sortable by product name.")
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the the filter will appear on the " "product listing page."
)
return default_fields
[docs]class SortProductListByPrice(SimpleProductListModifier):
is_active_key = "sort_products_by_price"
is_active_label = _("Sort products by price")
ordering_key = "sort_products_by_price_ordering"
ordering_label = _("Ordering for sort by price")
[docs] def get_fields(self, request, category=None):
return [
(
"sort",
forms.CharField(required=False, widget=forms.Select(), label=get_form_field_label("sort", _("Sort"))),
)
]
[docs] def get_choices_for_fields(self):
return [
(
"sort",
[
("price_a", get_form_field_label("price_a", _("Price - Low to High"))),
("price_d", get_form_field_label("price_d", _("Price - High to Low"))),
],
),
]
[docs] def sort_products(self, request, products, data):
sort = data.get("sort")
def _get_product_price_getter_for_request(request):
def _get_product_price(product):
return product.get_price(request)
return _get_product_price
if not sort:
sort = ""
key = sort[:-2] if sort.endswith(("_a", "_d")) else sort
if key == "price":
reverse = bool(sort.endswith("_d"))
sorter = _get_product_price_getter_for_request(request)
return sorted(products, key=sorter, reverse=reverse)
return products
[docs] def get_admin_fields(self):
default_fields = super(SortProductListByPrice, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow products to be sortable by price (from low to high; from high to low)."
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the the filter will appear on the " "product listing page."
)
return default_fields
[docs]class SortProductListByCreatedDate(SimpleProductListModifier):
is_active_key = "sort_products_by_date_created"
is_active_label = _("Sort products by date created")
ordering_key = "sort_products_by_date_created_ordering"
ordering_label = _("Ordering for sort by date created")
[docs] def get_fields(self, request, category=None):
return [
(
"sort",
forms.CharField(required=False, widget=forms.Select(), label=get_form_field_label("sort", _("Sort"))),
)
]
[docs] def get_choices_for_fields(self):
return [
(
"sort",
[
("created_date_d", get_form_field_label("created_date_d", _("Date created"))),
],
),
]
[docs] def sort_products(self, request, products, data):
sort = data.get("sort")
def _get_product_created_on_datetime(product):
return product.created_on
if not sort:
sort = ""
key = sort[:-2] if sort.endswith(("_a", "_d")) else sort
if key == "created_date":
sorter = _get_product_created_on_datetime
reverse = bool(sort.endswith("_d"))
products = sorted(products, key=sorter, reverse=reverse)
return products
[docs] def get_admin_fields(self):
default_fields = super(SortProductListByCreatedDate, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow products to be sortable from newest to oldest products."
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the filter will appear on the " "product listing page."
)
return default_fields
[docs]class SortProductListByAscendingCreatedDate(SortProductListByCreatedDate):
is_active_key = "sort_products_by_ascending_created_date"
is_active_label = _("Sort products by date created - oldest first")
ordering_key = "sort_products_by_ascending_created_date_ordering"
ordering_label = _("Ordering for sort by date created - oldest first")
[docs] def get_choices_for_fields(self):
return [
(
"sort",
[
("created_date_a", get_form_field_label("created_date_a", _("Date created - oldest first"))),
],
),
]
[docs] def get_admin_fields(self):
default_fields = super(SortProductListByAscendingCreatedDate, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow products to be sortable from oldest to newest products."
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the filter will appear on the " "product listing page."
)
return default_fields
[docs]class ManufacturerProductListFilter(SimpleProductListModifier):
is_active_key = "filter_products_by_manufacturer"
is_active_label = _("Filter products by manufacturer")
ordering_key = "filter_products_by_manufacturer_ordering"
ordering_label = _("Ordering for filter by manufacturer")
[docs] def get_fields(self, request, category=None):
if not Manufacturer.objects.filter(Q(shops__isnull=True) | Q(shops=request.shop)).exists():
return
shop_products_qs = ShopProduct.objects.filter(shop=request.shop).exclude(
visibility=ShopProductVisibility.NOT_VISIBLE
)
if category:
shop_products_qs = shop_products_qs.filter(Q(primary_category=category) | Q(categories=category))
queryset = Manufacturer.objects.filter(
Q(product__shop_products__in=shop_products_qs), Q(shops=request.shop) | Q(shops__isnull=True)
).distinct()
if not queryset.exists():
return
return [
(
"manufacturers",
CommaSeparatedListField(
required=False,
label=get_form_field_label("manufacturers", _("Manufacturers")),
widget=FilterWidget(choices=[(mfgr.pk, mfgr.name) for mfgr in queryset]),
),
),
]
[docs] def get_filters(self, request, data):
manufacturers = data.get("manufacturers")
if manufacturers:
return Q(manufacturer__in=manufacturers)
[docs] def get_admin_fields(self):
default_fields = super(ManufacturerProductListFilter, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow products to be filterable by manufacturer for this category."
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the manufacturer filters will appear on the "
"product listing page."
)
return default_fields
[docs]class CategoryProductListFilter(SimpleProductListModifier):
is_active_key = "filter_products_by_category"
is_active_label = _("Filter products by category")
ordering_key = "filter_products_by_category_ordering"
ordering_label = _("Ordering for filter by category")
[docs] def get_fields(self, request, category=None):
if not Category.objects.filter(shops=request.shop).exists():
return
key, val = context_cache.get_cached_value(
identifier="categoryproductfilter", item=self, context=request, category=category
)
if val:
return val
language = get_language()
base_queryset = Category.objects.all_visible(request.customer, request.shop, language=language)
if category:
q = Q(
Q(shop_products__categories=category), ~Q(shop_products__visibility=ShopProductVisibility.NOT_VISIBLE)
)
queryset = base_queryset.filter(q).exclude(pk=category.pk).distinct()
else:
# Show only first level when there is no category selected
queryset = base_queryset.filter(parent=None)
data = [
(
"categories",
CommaSeparatedListField(
required=False,
label=get_form_field_label("categories", _("Categories")),
widget=FilterWidget(choices=[(cat.pk, cat.name) for cat in queryset]),
),
)
]
context_cache.set_cached_value(key, data)
return data
[docs] def get_filters(self, request, data):
categories = data.get("categories")
if not categories:
return
if not isinstance(categories, (list, tuple)):
categories = list(categories)
categories = [cat.strip() for cat in categories if cat]
if categories:
return Q(
shop_products__categories__in=Category.objects.get_queryset_descendants(
Category.objects.filter(pk__in=categories), include_self=True
)
)
[docs] def get_admin_fields(self):
default_fields = super(CategoryProductListFilter, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow products to be filterable by any visible product category. "
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the category list filters will appear on the "
"product listing page."
)
return default_fields
[docs]class LimitProductListPageSize(SimpleProductListModifier):
is_active_key = "limit_product_list_page_size"
is_active_label = _("Limit page size")
ordering_key = "limit_product_list_page_size_ordering"
ordering_label = _("Ordering for limit page size")
[docs] def get_fields(self, request, category=None):
return [
(
"limit",
forms.IntegerField(
required=False, widget=forms.Select(), label=get_form_field_label("limit", _("Products per page"))
),
)
]
[docs] def get_choices_for_fields(self):
return [
("limit", [(12, 12), (24, 24), (36, 36), (48, 48)]),
]
[docs] def get_admin_fields(self):
default_fields = super(LimitProductListPageSize, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow the customer to be able to select the number of products to display."
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the page size filter will appear on the "
"product listing page."
)
return default_fields
[docs]class ProductVariationFilter(SimpleProductListModifier):
is_active_key = "filter_products_by_variation_value"
is_active_label = _("Filter products by variation")
ordering_key = "filter_products_by_variation_value_ordering"
ordering_label = _("Ordering for filter by variation")
[docs] def get_fields(self, request, category=None):
if not category:
return
key, val = context_cache.get_cached_value(
identifier="productvariationfilter", item=self, context=request, category=category
)
if val:
return val
variation_values = defaultdict(set)
for variation in ProductVariationVariable.objects.filter(
Q(product__shop_products__categories=category),
~Q(product__shop_products__visibility=ShopProductVisibility.NOT_VISIBLE),
):
for value in variation.values.all():
# TODO: Use ID here instead of this "trick"
choices = (value.value.replace(" ", "*"), value.value)
variation_values[slugify(variation.name)].add(choices)
fields = []
for variation_key, choices in six.iteritems(variation_values):
fields.append(
(
"variation_%s" % variation_key,
CommaSeparatedListField(
required=False, label=capfirst(variation_key), widget=FilterWidget(choices=choices)
),
)
)
context_cache.set_cached_value(key, fields)
return fields
[docs] def get_queryset(self, queryset, data):
if not any([key for key in data.keys() if key.startswith("variation")]):
return
for key, values in six.iteritems(data):
if key.startswith("variation"):
variation_query = Q()
for value in list(values):
# TODO: When using id this should search value for id
variation_query |= Q(
variation_variables__values__translations__value__iexact=value.replace("*", " ")
)
queryset = queryset.filter(variation_query)
return queryset
[docs] def get_admin_fields(self):
default_fields = super(ProductVariationFilter, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow products to be filterable by their different variations. "
"For example, size or color."
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the variation filters will appear on the "
"product listing page."
)
return default_fields
[docs]class ProductPriceFilter(SimpleProductListModifier):
is_active_key = "filter_products_by_price"
is_active_label = _("Filter products by price")
ordering_key = "filter_products_by_price_ordering"
ordering_label = _("Ordering for filter by price")
range_min_key = "filter_products_by_price_range_min"
range_max_key = "filter_products_by_price_range_max"
range_size_key = "filter_products_by_price_range_size"
[docs] def get_fields(self, request, category=None):
if not category:
return
# TODO: Add cache
configuration = get_configuration(request.shop, category)
min_price = configuration.get(self.range_min_key)
max_price = configuration.get(self.range_max_key)
range_size = configuration.get(self.range_size_key)
if not (min_price and max_price and range_size):
return
choices = [(None, "-------")] + get_price_ranges(request.shop, min_price, max_price, range_size)
return [
(
"price_range",
forms.ChoiceField(
required=False, choices=choices, label=get_form_field_label("price_range", _("Price"))
),
),
]
[docs] def filter_products(self, request, products, data):
selected_range = data.get("price_range")
if not selected_range:
return products
min_price, max_price = selected_range.split("-", 1)
min_price_value = decimal.Decimal(min_price or 0)
max_price_value = decimal.Decimal(max_price or 0)
filtered_products = []
for product in products:
price_value = product.get_price(request).amount.value
if price_value >= min_price_value and (max_price == "" or price_value < max_price_value):
filtered_products.append(product)
return filtered_products
[docs] def get_admin_fields(self):
default_fields = super(ProductPriceFilter, self).get_admin_fields()
default_fields[0][1].help_text = _(
"Enable this to allow products to be filtered by price. "
"Prices will be listed in groups from the price range minimum to price range maximum in increments of "
"the configured price range step."
)
default_fields[1][1].help_text = _(
"Use a numeric value to set the order in which the price range filters will appear on the "
"product listing page."
)
min_field = forms.IntegerField(
label=_("Price range minimum"),
min_value=0,
required=False,
help_text=_("Set the minimum price for the filter. The first range will be from zero to this value."),
)
max_field = forms.IntegerField(
label=_("Price range maximum"),
min_value=0,
required=False,
help_text=_("Set the maximum price for the filter. The last range will include this value and above."),
)
range_step = forms.IntegerField(
label=_("Price range step"),
min_value=0,
required=False,
help_text=_("Set the price step for each range. Each range will increment by this value."),
)
return default_fields + [
(self.range_min_key, min_field),
(self.range_max_key, max_field),
(self.range_size_key, range_step),
]
[docs]class AttributeProductListFilter(SimpleProductListModifier):
is_active_key = "filter_products_by_products_attribute"
is_active_label = _("Filter products by its attributes")
ordering_key = "filter_products_by_attribute_ordering"
product_attr_key = "filter_products_by_product_attribute_field"
def _build_attribute_filter_fields(
self,
attributes,
):
fields = []
for attribute in attributes:
if attribute.type == AttributeType.CHOICES and attribute.choices.exists():
fields.append(
[
attribute.identifier,
CommaSeparatedListField(
required=False,
widget=FilterWidget(
choices=[(choice.id, choice.name) for choice in attribute.choices.all()],
),
label=_(attribute.name),
),
]
)
return fields
def _get_attributes_from_shop_config(self, shop):
config = get_configuration(shop)
filterable_attribute_pks = config.get(self.product_attr_key)
attributes = Attribute.objects.all()
if filterable_attribute_pks:
return attributes.filter(pk__in=filterable_attribute_pks)
return attributes
def _get_attributes_from_category(self, shop, category):
category_config = shuup_config.get(shop, _get_category_configuration_key(category))
attributes = Attribute.objects.all()
if category_config and category_config.get("override_default_configuration", False):
filterable_attribute_pks = category_config.get(self.product_attr_key)
else:
config = get_configuration(shop)
filterable_attribute_pks = config.get(self.product_attr_key)
if filterable_attribute_pks:
return attributes.filter(pk__in=filterable_attribute_pks)
return attributes
[docs] def get_fields(
self,
request,
category=None,
):
if category:
attributes = self._get_attributes_from_category(request.shop, category)
else:
attributes = self._get_attributes_from_shop_config(request.shop)
return self._build_attribute_filter_fields(attributes)
def _get_product_attribute_query_strings(self, data):
"""
Get product attribute in querystring that has truthy values
"""
attribute_identifiers = Attribute.objects.all().values_list("identifier", flat=True)
attribute_query_strings = [key for key, value in data.items() if value and key in attribute_identifiers]
return attribute_query_strings
[docs] def get_queryset(self, queryset, data):
# Filter for chosen attributes
attributes = self._get_product_attribute_query_strings(data)
if not attributes:
return queryset
for attribute in attributes:
values = data.get(attribute, [])
queryset = queryset.filter(
attributes__attribute__identifier=attribute,
attributes__chosen_options__id__in=values,
)
return queryset
[docs] def get_admin_fields(self):
active, ordering = super(AttributeProductListFilter, self).get_admin_fields()
active[1].help_text = _("Allow products to be filtered according to their attributes.")
attributes = Select2MultipleField(
model=Attribute,
label=_("Attributes that can be filtered"),
required=False,
help_text=_("Select attributes that can used for filtering the products."),
)
return [active, (self.product_attr_key, attributes), ordering]
[docs] def clean_hook(self, form):
attribute_query_strings = self._get_product_attribute_query_strings(form.data)
for attribute_query_string in attribute_query_strings:
form.cleaned_data[attribute_query_string] = form.data.get(attribute_query_string).split(",")
return super().clean_hook(form)
[docs]def get_price_ranges(shop, min_price, max_price, range_step):
if range_step == 0:
return
ranges = []
min_price_value = format_money(shop.create_price(min_price))
ranges.append(("-%s" % min_price, _("Under %(min_limit)s") % {"min_limit": min_price_value}))
for range_min in range(min_price, max_price, range_step):
range_min_price = format_money(shop.create_price(range_min))
range_max = range_min + range_step
if range_max < max_price:
range_max_price = format_money(shop.create_price(range_max))
ranges.append(
(
"%s-%s" % (range_min, range_max),
_("%(min)s to %(max)s") % dict(min=range_min_price, max=range_max_price),
)
)
max_price_value = format_money(shop.create_price(max_price))
ranges.append(("%s-" % max_price, _("%(max_limit)s & Above") % {"max_limit": max_price_value}))
return ranges