[IMP] website_sale: allow using alternative products in dynamic snippet
With this commit we replace the current way to show alternative products with a new option on the dynamic snippets. Instead of having alternative products show up on all products if the option is enabled, it will now be on a per product (template that is) basis using the dynamic product snippet. TaskId-2695187 closes odoo/odoo#85189 Related: odoo/upgrade#3277 Signed-off-by: William Braeckman (wbr) <wbr@odoo.com>
This commit is contained in:
@@ -110,6 +110,16 @@ model.env.context['product_template_id'] = request.params.get('productTemplateId
|
||||
response = DynamicFilter._get_products('recently_sold_with', model.env.context)
|
||||
</field>
|
||||
</record>
|
||||
<record id="dynamic_snippet_alternative_products" model="ir.actions.server">
|
||||
<field name="name">Alternative Products</field>
|
||||
<field name="model_id" ref="model_product_product"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
DynamicFilter = model.env['website.snippet.filter']
|
||||
model.env.context['product_template_id'] = request.params.get('productTemplateId')
|
||||
response = DynamicFilter._get_products('alternative_products', model.env.context)
|
||||
</field>
|
||||
</record>
|
||||
<!-- Dynamic Filter -->
|
||||
<record id="dynamic_filter_newest_products" model="website.snippet.filter">
|
||||
<field name="filter_id" ref="website_sale.dynamic_snippet_newest_products_filter"/>
|
||||
@@ -143,6 +153,13 @@ response = DynamicFilter._get_products('recently_sold_with', model.env.context)
|
||||
<field name="name">Products Recently Sold With Product</field>
|
||||
<field name="product_cross_selling">True</field>
|
||||
</record>
|
||||
<record id="dynamic_filter_cross_selling_alternative_products" model="website.snippet.filter">
|
||||
<field name="action_server_id" ref="website_sale.dynamic_snippet_alternative_products"/>
|
||||
<field name="field_names">display_name,description_sale,image_512</field>
|
||||
<field name="limit" eval="16"/>
|
||||
<field name="name">Alternative Products</field>
|
||||
<field name="product_cross_selling">True</field>
|
||||
</record>
|
||||
|
||||
<function model="ir.model.fields" name="formbuilder_whitelist">
|
||||
<value>sale.order</value>
|
||||
|
||||
@@ -382,6 +382,10 @@ class ProductTemplate(models.Model):
|
||||
return self.website_ribbon_id
|
||||
return self.product_tag_ids.ribbon_id[:1] or self.product_variant_ids.additional_product_tag_ids.ribbon_id[:1]
|
||||
|
||||
@api.model
|
||||
def _get_alternative_product_filter(self):
|
||||
return self.env.ref('website_sale.dynamic_filter_cross_selling_alternative_products').id
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Rating Mixin API
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -157,3 +157,22 @@ class WebsiteSnippetFilter(models.Model):
|
||||
])
|
||||
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
|
||||
return products
|
||||
|
||||
def _get_products_alternative_products(self, website, limit, domain, context):
|
||||
products = self.env['product.product']
|
||||
current_id = context.get('product_template_id')
|
||||
if not current_id:
|
||||
return products
|
||||
current_template = self.env['product.template'].browse(int(current_id))
|
||||
if current_template.exists():
|
||||
excluded_products = website.sale_get_order().order_line.product_id
|
||||
excluded_products |= current_template.product_variant_ids
|
||||
included_products = current_template.alternative_product_ids.product_variant_ids
|
||||
products = included_products - excluded_products
|
||||
if products:
|
||||
domain = expression.AND([
|
||||
domain,
|
||||
[('id', 'in', products.ids)],
|
||||
])
|
||||
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
|
||||
return products
|
||||
|
||||
@@ -766,4 +766,31 @@ options.registry.WebsiteSaleProductAttribute = options.Class.extend({
|
||||
return this._super(methodName, params);
|
||||
},
|
||||
});
|
||||
|
||||
// Disable "Shown on Mobile" option if for dynamic product snippets
|
||||
options.registry.MobileVisibility.include({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _computeVisibility() {
|
||||
return await this._super(...arguments)
|
||||
&& !this.$target.hasClass('s_dynamic_snippet_products');
|
||||
},
|
||||
});
|
||||
|
||||
// Disable save for alternative products snippet
|
||||
options.registry.SnippetSave.include({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _computeVisibility() {
|
||||
return await this._super(...arguments)
|
||||
&& !this.$target.hasClass('o_wsale_alternative_products');
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -81,10 +81,19 @@ const DynamicSnippetProducts = DynamicSnippetCarousel.extend({
|
||||
if (productNames) {
|
||||
const nameDomain = [];
|
||||
for (const productName of productNames.split(',')) {
|
||||
// Ignore empty names
|
||||
if (!productName.length) {
|
||||
continue;
|
||||
}
|
||||
// Search on name, internal reference and barcode.
|
||||
if (nameDomain.length) {
|
||||
nameDomain.unshift('|');
|
||||
}
|
||||
nameDomain.push(['name', 'ilike', productName]);
|
||||
nameDomain.push(...[
|
||||
'|', '|', ['name', 'ilike', productName],
|
||||
['default_code', '=', productName],
|
||||
['barcode', '=', productName],
|
||||
]);
|
||||
}
|
||||
searchDomain.push(...nameDomain);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ const s_dynamic_snippet_carousel_options = require('website.s_dynamic_snippet_ca
|
||||
|
||||
var wUtils = require('website.utils');
|
||||
|
||||
const alternativeSnippetRemovedOptions = [
|
||||
'filter_opt', 'product_category_opt', 'product_tag_opt', 'product_names_opt',
|
||||
]
|
||||
|
||||
const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend({
|
||||
|
||||
/**
|
||||
@@ -23,11 +27,22 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend(
|
||||
this.contextualFilterDomain.push(['product_cross_selling', '=', false]);
|
||||
}
|
||||
this.productCategories = {};
|
||||
this.isAlternativeProductSnippet = this.$target.hasClass('o_wsale_alternative_products');
|
||||
},
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_computeWidgetVisibility(widgetName, params) {
|
||||
if (this.isAlternativeProductSnippet && alternativeSnippetRemovedOptions.includes(widgetName)) {
|
||||
return false;
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
/**
|
||||
* Fetches product categories.
|
||||
* @private
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
data-select-data-attribute=""/>
|
||||
<we-input string="Product names" class="o_we_large" data-name="product_names_opt"
|
||||
data-attribute-name="productNames" data-no-preview="true" data-select-data-attribute=""
|
||||
placeholder="e.g. lamp,bin" title="Comma-separated list of parts of product names"/>
|
||||
placeholder="e.g. lamp,bin" title="Comma-separated list of parts of product names, barcodes or internal reference"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
@@ -724,6 +724,28 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="alternative_products" name="Alternative Products" inherit_id="website_sale.product" active="True">
|
||||
<div itemprop="description" position="after">
|
||||
<div class="oe_structure oe_structure_solo oe_unremovable oe_unmovable" id="oe_structure_website_sale_recommended_products" t-ignore="true" t-if="product.alternative_product_ids">
|
||||
<section data-snippet="s_dynamic_snippet_products"
|
||||
class="oe_unmovable oe_unremovable s_dynamic_snippet_products o_wsale_alternative_products s_dynamic pt32 pb32 o_colored_level s_product_product_borderless_1 d-none"
|
||||
data-name="Alternative Products" style="background-image: none;" t-att-data-filter-id="product._get_alternative_product_filter()"
|
||||
data-template-key="website_sale.dynamic_filter_template_product_product_borderless_1" data-product-category-id="all" data-number-of-elements="4"
|
||||
data-number-of-elements-small-devices="1" data-number-of-records="16" data-carousel-interval="5000" data-original-title="" title="">
|
||||
<div class="container o_not_editable">
|
||||
<div class="css_non_editable_mode_hidden">
|
||||
<div class="missing_option_warning alert alert-info rounded-0 fade show d-none d-print-none o_default_snippet_text">
|
||||
Your Dynamic Snippet will be displayed here...
|
||||
This message is displayed because youy did not provide both a filter and a template to use.
|
||||
</div>
|
||||
</div>
|
||||
<div class="dynamic_snippet_template"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="product_custom_text" inherit_id="website_sale.product" active="True" name="Terms and Conditions" priority="21">
|
||||
<xpath expr="//div[@id='o_product_terms_and_share']" position="inside">
|
||||
<p class="text-muted mb-0">
|
||||
@@ -761,36 +783,6 @@
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="recommended_products" inherit_id="website_sale.product" name="Alternative Products">
|
||||
<xpath expr="//div[@id='product_full_description']" position="after">
|
||||
<t t-set="alternative_products" t-value="product._get_website_alternative_product()"/>
|
||||
<div t-if="alternative_products" class="container mt32">
|
||||
<h3>Alternative Products:</h3>
|
||||
<div class="row mt16" style="">
|
||||
<t t-foreach="alternative_products" t-as="alt_product">
|
||||
<div class="col-lg-2" t-att-data-publish="alt_product.website_published and 'on' or 'off'" style="width: 170px; height:130px; float:left; display:inline; margin-right: 10px; overflow:hidden;">
|
||||
<div class="mt16 text-center" style="height: 100%;">
|
||||
<t t-set="first_combination" t-value="alt_product._get_first_possible_combination(False)"/>
|
||||
<t t-set="alternative_variant" t-value="alt_product._get_variant_for_combination(combination)"/>
|
||||
<div t-if="alternative_variant"
|
||||
t-field="alternative_variant.image_128"
|
||||
t-options="{'widget': 'image', 'qweb_img_responsive': False, 'class': 'rounded shadow o_alternative_product o_image_64_max' }"/>
|
||||
<div t-else=""
|
||||
t-field="alt_product.image_128"
|
||||
t-options="{'widget': 'image', 'qweb_img_responsive': False, 'class': 'rounded shadow o_alternative_product o_image_64_max' }"/>
|
||||
<h6>
|
||||
<a t-att-href="alt_product.website_url" style="display: block">
|
||||
<span t-att-title="alt_product.name" t-field="alt_product.name" class="o_text_overflow" style="display: block;" />
|
||||
</a>
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Product options: OpenChatter -->
|
||||
<template id="product_comment" inherit_id="website_sale.product" active="False" name="Discussion and Rating" priority="15">
|
||||
<xpath expr="//div[@t-field='product.website_description']" position="after">
|
||||
|
||||
@@ -2,7 +2,6 @@ odoo.define('website_sale_comparison.tour_comparison', function (require) {
|
||||
'use strict';
|
||||
|
||||
var tour = require('web_tour.tour');
|
||||
var rpc = require('web.rpc');
|
||||
const tourUtils = require('website_sale.tour_utils');
|
||||
|
||||
tour.register('product_comparison', {
|
||||
@@ -166,79 +165,5 @@ odoo.define('website_sale_comparison.tour_comparison', function (require) {
|
||||
trigger: '#cart_products:contains("Conference Chair") .js_quantity[value="1"]',
|
||||
run: function () {},
|
||||
},
|
||||
// test advanced configuration and alternative product
|
||||
{
|
||||
content: "create product with newly created attribute and its values and set alternative product",
|
||||
trigger: 'body',
|
||||
run: function () {
|
||||
rpc.query({
|
||||
model: 'product.attribute',
|
||||
method: 'create',
|
||||
args: [{
|
||||
'name': 'color',
|
||||
'display_type': 'color',
|
||||
'create_variant': 'dynamic',
|
||||
}],
|
||||
}).then(function (attributeId) {
|
||||
rpc.query({
|
||||
model: 'product.template',
|
||||
method: 'create',
|
||||
args: [{
|
||||
'name': 'Basic Chair',
|
||||
'is_published': true,
|
||||
'attribute_line_ids': [[0, 0, {
|
||||
'attribute_id': attributeId,
|
||||
'value_ids': [
|
||||
[0, 0, {'name': 'red', 'attribute_id': attributeId, 'sequence': 1}],
|
||||
[0, 0, {'name': 'blue', 'attribute_id': attributeId, 'sequence': 2}],
|
||||
],
|
||||
}]],
|
||||
}],
|
||||
}).then(function (productId) {
|
||||
rpc.query({
|
||||
model: 'product.template',
|
||||
method: 'create',
|
||||
args: [{
|
||||
'name': 'Classic Chair',
|
||||
'is_published': true,
|
||||
'attribute_line_ids': [[0, 0, {
|
||||
'attribute_id': attributeId,
|
||||
'value_ids': [
|
||||
[0, 0, {'name': 'green', 'attribute_id': attributeId, 'sequence': 3}],
|
||||
[0, 0, {'name': 'yellow', 'attribute_id': attributeId, 'sequence': 4}],
|
||||
],
|
||||
}]],
|
||||
'alternative_product_ids': [[6, 0, [productId]]],
|
||||
}],
|
||||
}).then(function () {
|
||||
window.location.href = '/shop?search=Classic Chair';
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "click on product",
|
||||
trigger: '.oe_product_cart a:contains("Classic Chair")',
|
||||
},
|
||||
{
|
||||
content: "click on compare button",
|
||||
trigger: '.btn.btn-primary:not(.btn-block):contains("Compare")',
|
||||
},
|
||||
{
|
||||
content: "check product 'Classic Chair' with first variant (green) is on compare page",
|
||||
trigger: '.o_product_comparison_table:contains("Classic Chair (green)")',
|
||||
run: function () {},
|
||||
},
|
||||
{
|
||||
content: "check alternative product 'Basic Chair' with first variant (red) is on compare page",
|
||||
trigger: '.o_product_comparison_table:contains("Basic Chair (red)")',
|
||||
run: function () {},
|
||||
},
|
||||
{
|
||||
content: "check there is the correct attribute",
|
||||
trigger: '.o_ws_category_0:contains("color"):contains("red")',
|
||||
run: function () {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -64,24 +64,6 @@
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id='recommended_product' inherit_id="website_sale.recommended_products" name="Alternative Products">
|
||||
<xpath expr="//h3" position="replace">
|
||||
<!--
|
||||
Create the first variant if it doesn't exist for the current
|
||||
product and every alternative product because the product
|
||||
comparator need the variants and not the templates.
|
||||
-->
|
||||
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
|
||||
<t t-set="id_list" t-value="[product_variant.id] if product_variant else []"/>
|
||||
<t t-foreach="product._get_website_alternative_product()" t-as="alt_product">
|
||||
<t t-set="alt_product_variant_id" t-value="alt_product._create_first_product_variant().id"/>
|
||||
<t t-if="alt_product_variant_id" t-set="id_list" t-value="id_list + [alt_product_variant_id]"/>
|
||||
</t>
|
||||
<t t-set="ids" t-value="','.join([str(id) for id in id_list])"/>
|
||||
<div><span class='h3'>Suggested alternatives: </span><a t-if="len(id_list) > 1 and is_view_active('website_sale_comparison.add_to_compare')" role="button" class="btn btn-primary" t-attf-href="/shop/compare?products=#{ids}"><i class="fa fa-exchange"></i> Compare</a></div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="product_compare" name="Comparator Page">
|
||||
<t t-call="website.layout">
|
||||
<t t-set="additional_title">Shop Comparator</t>
|
||||
|
||||
Reference in New Issue
Block a user