[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) <wbr@odoo.com>
This commit is contained in:
Donnie
2022-07-26 13:25:05 +02:00
committed by William Braeckman
parent c1aa010af7
commit 91d0e9c645
5 changed files with 228 additions and 7 deletions
+49
View File
@@ -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.
@@ -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();
@@ -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: () => {}
},
]
);
+1
View File
@@ -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
@@ -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")