From 91d0e9c645abdc0333c850a9fb9336272db02dd3 Mon Sep 17 00:00:00 2001 From: Donnie Date: Tue, 19 Apr 2022 09:36:46 +0000 Subject: [PATCH] [IMP] website_sale: exclude the excluded variants from attribute search Filter product templates search result if there are any possible attribute combination to get/create a product variant regarding the searched attributes. For example if the combination (A, B) is excluded, filtering on (A, B) will filter that product out of the result. task-2558970 closes odoo/odoo#89018 Signed-off-by: William Braeckman (wbr) --- addons/website_sale/controllers/main.py | 49 ++++++++++ .../static/src/js/website_sale.js | 53 +++++++++-- .../website_sale_shop_variant_exclusion.js | 39 ++++++++ addons/website_sale/tests/__init__.py | 1 + ...est_website_sale_shop_variant_exclusion.py | 93 +++++++++++++++++++ 5 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 addons/website_sale/static/tests/tours/website_sale_shop_variant_exclusion.js create mode 100644 addons/website_sale/tests/test_website_sale_shop_variant_exclusion.py diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index e5a6a3a83c8..6a1abdb4459 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict +from itertools import product as cartesian_product import json import logging from datetime import datetime @@ -297,6 +299,53 @@ class WebsiteSale(http.Controller): limit=None, order=self._get_search_order(post), options=options) search_product = details[0].get('results', request.env['product.template']).with_context(bin_size=True) + if attrib_set: + # Attributes value per attribute + attribute_values = request.env['product.attribute.value'].browse(attrib_set) + values_per_attribute = defaultdict(lambda: request.env['product.attribute.value']) + # In case we have only one value per attribute we can check for a combination using those attributes directly + multi_value_attribute = False + for value in attribute_values: + values_per_attribute[value.attribute_id] |= value + if len(values_per_attribute[value.attribute_id]) > 1: + multi_value_attribute = True + + def filter_template(template, attribute_values_list): + # Transform product.attribute.value to product.template.attribute.value + attribute_value_to_ptav = dict() + for ptav in template.attribute_line_ids.product_template_value_ids: + attribute_value_to_ptav[ptav.product_attribute_value_id] = ptav.id + possible_combinations = False + for attribute_values in attribute_values_list: + ptavs = request.env['product.template.attribute.value'].browse( + [attribute_value_to_ptav[val] for val in attribute_values if val in attribute_value_to_ptav] + ) + if len(ptavs) < len(attribute_values): + # In this case the template is not compatible with this specific combination + continue + if len(ptavs) == len(template.attribute_line_ids): + if template._is_combination_possible(ptavs): + return True + elif len(ptavs) < len(template.attribute_line_ids): + if len(attribute_values_list) == 1: + if any(template._get_possible_combinations(necessary_values=ptavs)): + return True + if not possible_combinations: + possible_combinations = template._get_possible_combinations() + if any(len(ptavs & combination) == len(ptavs) for combination in possible_combinations): + return True + return False + + # If multi_value_attribute is False we know that we have our final combination (or at least a subset of it) + if not multi_value_attribute: + possible_attrib_values_list = [attribute_values] + else: + # Cartesian product from dict keys and values + possible_attrib_values_list = [request.env['product.attribute.value'].browse([v.id for v in values]) for + values in cartesian_product(*values_per_attribute.values())] + + search_product = search_product.filtered(lambda tmpl: filter_template(tmpl, possible_attrib_values_list)) + filter_by_price_enabled = website.is_view_active('website_sale.filter_products_price') if filter_by_price_enabled: # TODO Find an alternative way to obtain the domain through the search metadata. diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index e0f4934b1f1..72410f5fed7 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -204,6 +204,7 @@ const cartHandlerMixin = wSaleUtils.cartHandlerMixin; require("web.zoomodoo"); const {extraMenuUpdateCallbacks} = require('website.content.menu'); const dom = require('web.dom'); +const { cartesian } = require('@web/core/utils/arrays'); publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, cartHandlerMixin, { selector: '.oe_website_sale', @@ -773,23 +774,61 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car $('.toggle_summary_div').toggleClass('d-none'); $('.toggle_summary_div').removeClass('d-xl-block'); }, + /** + * @private + */ + _isValidCombination(combination, attributeExclusions) { + if (attributeExclusions.exclusions) { + for (const attribute of combination) { + if (!attributeExclusions.exclusions.hasOwnProperty(attribute)) { + continue; + } + for (const excludedAttribute of attributeExclusions.exclusions[attribute]) { + if (combination.includes(excludedAttribute)) { + return false; + } + } + } + } + if (attributeExclusions.archived_combination) { + for (const archivedCombination of attributeExclusions.archived_combination) { + if (archivedCombination.length !== combination.length) { + continue; + } + if (archivedCombination.filter((attr) => combination.includes(attr)).length === combination.length) { + return false; + } + } + } + return true; + }, /** * @private */ _applyHashFromSearch() { const params = $.deparam(window.location.search.slice(1)); if (params.attrib) { - const dataValueIds = []; - for (const attrib of [].concat(params.attrib)) { - const attribSplit = attrib.split('-'); - const attribValueSelector = `.js_variant_change[name="ptal-${attribSplit[0]}"][value="${attribSplit[1]}"]`; + const attributeValuesPerAttribute = {}; + for (const attrib of params.attrib) { + const [ptalId, ptavId] = attrib.split('-'); + const attribValueSelector = `.js_variant_change[name="ptal-${ptalId}"][value="${ptavId}"]`; const attribValue = this.el.querySelector(attribValueSelector); if (attribValue !== null) { - dataValueIds.push(attribValue.dataset.value_id); + if (!attributeValuesPerAttribute[ptalId]) { + attributeValuesPerAttribute[ptalId] = []; + } + attributeValuesPerAttribute[ptalId].push(ptavId); } } - if (dataValueIds.length) { - window.location.hash = `attr=${dataValueIds.join(',')}`; + const attributeSelection = this.el.querySelector('.js_add_cart_variants'); + const attributeExclusions = attributeSelection && JSON.parse(attributeSelection.dataset.attribute_exclusions); + if (attributeExclusions && Object.values(attributeValuesPerAttribute).length > 1) { + const allCombinations = cartesian(...Object.values(attributeValuesPerAttribute)); + const selectedCombination = allCombinations.find(c => this._isValidCombination(c, attributeExclusions)); + + if (selectedCombination && selectedCombination.length) { + window.location.hash = `attr=${selectedCombination.join(',')}`; + } } } this._applyHash(); diff --git a/addons/website_sale/static/tests/tours/website_sale_shop_variant_exclusion.js b/addons/website_sale/static/tests/tours/website_sale_shop_variant_exclusion.js new file mode 100644 index 00000000000..b472db70249 --- /dev/null +++ b/addons/website_sale/static/tests/tours/website_sale_shop_variant_exclusion.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import tour from 'web_tour.tour'; + +tour.register('shop_variant_exclusion', { + url: '/shop', + test: true, + }, + [ + { + content: "select product attribute First Attribute - Value 1", + trigger: 'form.js_attributes input:not(:checked) + label:contains(First Attribute - Value 1)', + }, + { + content: "select product attribute Second Attribute - Value 2", + trigger: 'form.js_attributes input:not(:checked) + label:contains(Second Attribute - Value 2)', + }, + { + content: "check for product template", + trigger: '[data-oe-expression="product.name"]:contains(Test Product)', + run: () => {} + }, + { + content: "deselect product attribute Second Attribute - Value 2", + trigger: 'form.js_attributes input:checked + label:contains(Second Attribute - Value 2)', + }, + { + content: "select product attribute Second Attribute - Value 1", + extra_trigger: 'body:not(:has(#customize-menu:visible .dropdown-menu:visible))', + trigger: 'form.js_attributes input:not(:checked) + label:contains(Second Attribute - Value 1)', + }, + { + content: "check for no product defined", + trigger: "h3:contains(No product defined)", + run: () => {} + }, + ] +); + diff --git a/addons/website_sale/tests/__init__.py b/addons/website_sale/tests/__init__.py index 9d6ce71e3c3..a5b1fb96c0f 100644 --- a/addons/website_sale/tests/__init__.py +++ b/addons/website_sale/tests/__init__.py @@ -15,3 +15,4 @@ from . import test_website_sequence from . import test_website_sale_show_compare_list_price from . import test_website_sale_visitor from . import test_website_sale_product +from . import test_website_sale_shop_variant_exclusion diff --git a/addons/website_sale/tests/test_website_sale_shop_variant_exclusion.py b/addons/website_sale/tests/test_website_sale_shop_variant_exclusion.py new file mode 100644 index 00000000000..dffd57ce3f9 --- /dev/null +++ b/addons/website_sale/tests/test_website_sale_shop_variant_exclusion.py @@ -0,0 +1,93 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.base.tests.common import HttpCaseWithUserDemo, HttpCaseWithUserPortal +from odoo.tests import tagged + +@tagged('post_install', '-at_install') +class TestShopVariantExclusion(HttpCaseWithUserDemo, HttpCaseWithUserPortal): + + @classmethod + def setUpClass(cls): + super(TestShopVariantExclusion, cls).setUpClass() + # create a template + cls.product_template = cls.env['product.template'].create({ + 'name': 'Test Product', + 'is_published': True, + 'list_price': 750, + }) + + cls.first_product_attribute = cls.env['product.attribute'].create({ + 'name': 'First Attribute', + 'visibility': 'visible', + 'sequence': 10, + }) + + cls.second_product_attribute = cls.env['product.attribute'].create({ + 'name': 'Second Attribute', + 'visibility': 'visible', + }) + + cls.product_attribute_value_1 = cls.env['product.attribute.value'].create({ + 'name': 'First Attribute - Value 1', + 'attribute_id': cls.first_product_attribute.id, + 'sequence': 1, + }) + cls.product_attribute_value_2 = cls.env['product.attribute.value'].create({ + 'name': 'First Attribute - Value 2', + 'attribute_id': cls.first_product_attribute.id, + }) + cls.product_attribute_value_3 = cls.env['product.attribute.value'].create({ + 'name': 'Second Attribute - Value 1', + 'attribute_id': cls.second_product_attribute.id, + }) + cls.product_attribute_value_4 = cls.env['product.attribute.value'].create({ + 'name': 'Second Attribute - Value 2', + 'attribute_id': cls.second_product_attribute.id, + }) + + # set attribute and attribute values on the template + cls.env['product.template.attribute.line'].create([{ + 'attribute_id': cls.first_product_attribute.id, + 'product_tmpl_id': cls.product_template.id, + 'value_ids': [(6, 0, [cls.product_attribute_value_1.id, cls.product_attribute_value_2.id])] + }]) + + cls.env['product.template.attribute.line'].create([{ + 'attribute_id': cls.second_product_attribute.id, + 'product_tmpl_id': cls.product_template.id, + 'value_ids': [(6, 0, [cls.product_attribute_value_3.id, cls.product_attribute_value_4.id])] + }]) + + def _add_exclude(self, ptav1, ptav2, product_template): + ptav1.update({ + 'exclude_for': [(0, 0, { + 'product_tmpl_id': product_template.id, + 'value_ids': [(6, 0, [ptav2.id])] + })] + }) + + def _get_product_template_attribute_value(self, product_attribute_value, model): + """ + Return the `product.template.attribute.value` matching + `product_attribute_value` for self. + + :param: recordset of one product.attribute.value + :return: recordset of one product.template.attribute.value if found + else empty + """ + return model.valid_product_template_attribute_line_ids.filtered( + lambda l: l.attribute_id == product_attribute_value.attribute_id + ).product_template_value_ids.filtered( + lambda v: v.product_attribute_value_id == product_attribute_value + ) + + def test_admin_shop_variant_exclusion(self): + # Enable Variant Group + self.env.ref('product.group_product_variant').write({'users': [(4, self.env.ref('base.user_admin').id)]}) + ptav1 = self._get_product_template_attribute_value(self.product_attribute_value_1, self.product_template) + ptav2 = self._get_product_template_attribute_value(self.product_attribute_value_3, self.product_template) + self._add_exclude(ptav1, ptav2, self.product_template) + # Enable Product Attributes Left Panel + self.env['ir.ui.view'].with_context(active_test=False).search( + [('key', '=', 'website_sale.products_attributes')]).write({'active': True}) + self.start_tour("/", 'shop_variant_exclusion', login="admin")