[ADD/MOV] sale_product_configurator: new module and files move

Purpose
=======
- Moved the product_configurator into its own module now that we have a specific option for it.
  The option only enables the p. configurator in the SO form.
  The e-commerce should always allow customer to configure their products, even if the option is not checked.

  This allows moving some of the code specific to the p. configurator into its own module.
  However, a lot of the code has to remain in "sale" module since the e-commerce does not require the option
  checked and needs the "configure product" feature in the shop by default.
  (This is why the fields "product_custom_attribute_value_ids" and "product_no_variant_attribute_value_ids"
  are still in the "sale" module)
This commit is contained in:
Aurélien Warnon
2019-03-27 15:12:50 +00:00
parent 14f0e81e20
commit 3cb46ec74c
37 changed files with 480 additions and 340 deletions
@@ -14,6 +14,7 @@ class ResConfigSettings(models.TransientModel):
" * Unchecked : Each company can see only its product (product where company is defined). Product not related to a company are visible for all companies.")
group_uom = fields.Boolean("Units of Measure", implied_group='uom.group_uom')
group_product_variant = fields.Boolean("Variants and Options", implied_group='product.group_product_variant')
module_sale_product_configurator = fields.Boolean("Product Configurator")
group_stock_packaging = fields.Boolean('Product Packagings',
implied_group='product.group_stock_packaging')
group_sale_pricelist = fields.Boolean("Use pricelists to adapt your price per customers",
@@ -33,6 +34,19 @@ class ResConfigSettings(models.TransientModel):
('1', 'Cubic Feet'),
], 'Volume unit of measure', config_parameter='product.volume_in_cubic_feet', default='0')
@api.onchange('group_product_variant')
def _onchange_group_product_variant(self):
"""The product Configurator requires the product variants activated.
If the user disables the product variants -> disable the product configurator as well"""
if self.module_sale_product_configurator and not self.group_product_variant:
self.module_sale_product_configurator = False
@api.onchange('module_sale_product_configurator')
def _onchange_module_sale_product_configurator(self):
"""The product Configurator requires the product variants activated
If the user enables the product configurator -> enable the product variants as well"""
if self.module_sale_product_configurator and not self.group_product_variant:
self.group_product_variant = True
@api.model
def get_values(self):
+1 -2
View File
@@ -27,13 +27,12 @@ This module contains all the common features of Sales Management and eCommerce.
'views/res_partner_views.xml',
'views/mail_activity_views.xml',
'views/assets.xml',
'views/variant_templates.xml',
'views/sale_portal_templates.xml',
'views/sale_product_configurator_templates.xml',
'views/sale_onboarding_views.xml',
'views/res_config_settings_views.xml',
'views/payment_views.xml',
'views/product_attribute_views.xml',
'wizard/sale_product_configurator_views.xml',
],
'demo': [
'data/sale_demo.xml',
+1 -1
View File
@@ -3,4 +3,4 @@
from . import onboarding
from . import portal
from . import product_configurator
from . import variant
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class VariantController(http.Controller):
@http.route(['/sale/get_combination_info'], type='json', auth="user", methods=['POST'])
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
ProductTemplate = request.env['product.template']
if 'context' in kw:
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
return ProductTemplate.browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
@http.route(['/sale/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(product_template_attribute_value_ids)
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
return request.env['product.pricelist'].browse(int(pricelist_id or 0))
def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw):
"""deprecated, use product method"""
combination = request.env['product.template.attribute.value'].browse(combination)
return request.env['product.template'].browse(product_template_id)._get_combination_info(combination, product_id, add_qty, pricelist)
-17
View File
@@ -730,23 +730,6 @@ Thanks!</field>
<field name="confirmation_date" eval="datetime.now() - relativedelta(months=1)"/>
</record>
<record id="product_product_1_product_template" model="product.template">
<field name="name">Chair floor protection</field>
<field name="categ_id" ref="product.product_category_5"/>
<field name="list_price">12.0</field>
<field name="weight">0.01</field>
<field name="uom_id" ref="uom.product_uom_unit"/>
<field name="uom_po_id" ref="uom.product_uom_unit"/>
<field name="description_sale">Office chairs can harm your floor: protect it.</field>
<field name="image" type="base64" file="sale/static/img/floor_protection-image.png"/>
</record>
<record id="product.product_product_4_product_template" model="product.template">
<field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/>
</record>
<record id="product.product_product_11_product_template" model="product.template">
<field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/>
</record>
<record id="product_attribute_value_7" model="product.attribute.value">
<field name="name">Custom</field>
<field name="attribute_id" ref="product.product_attribute_1"/>
-10
View File
@@ -5,16 +5,6 @@ from odoo import api, fields, models
from odoo.tools.float_utils import float_round
class ProductTemplate(models.Model):
_inherit = "product.template"
optional_product_ids = fields.Many2many(
'product.template', 'product_optional_rel', 'src_id', 'dest_id',
string='Optional Products', help="Optional Products are suggested "
"whenever the customer hits *Add to Cart* (cross-sell strategy, "
"e.g. for computers: warranty, software, etc.).")
class ProductProduct(models.Model):
_inherit = 'product.product'
@@ -68,7 +68,7 @@ var ProductConfiguratorMixin = {
self._checkExclusions($parent, combination);
ajax.jsonRpc(this._getUri('/product_configurator/get_combination_info'), 'call', {
ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', {
product_template_id: parseInt($parent.find('.product_template_id').val()),
product_id: this._getProductId($parent),
combination: combination,
@@ -311,7 +311,7 @@ var ProductConfiguratorMixin = {
// We don't handle that compatibility because the previous code was
// not working either: it was making an RPC that failed with any
// non-admin user anyway. To use this feature, restart the server.
var route = '/product_configurator/create_product_variant';
var route = '/sale/create_product_variant';
if (useAjax) {
productReady = ajax.jsonRpc(route, 'call', params);
} else {
-6
View File
@@ -8,11 +8,6 @@
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale/static/src/js/sale.js"></script>
<script type="text/javascript" src="/sale/static/src/js/tour.js"></script>
<script type="text/javascript" src="/sale/static/src/js/product_configurator_mixin.js"></script>
<script type="text/javascript" src="/sale/static/src/js/product_configurator_renderer.js"></script>
<script type="text/javascript" src="/sale/static/src/js/product_configurator_controller.js"></script>
<script type="text/javascript" src="/sale/static/src/js/product_configurator_view.js"></script>
<script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script>
<script type="text/javascript" src="/sale/static/src/js/account_reconciliation.js"></script>
</xpath>
</template>
@@ -28,7 +23,6 @@
<template id="qunit_suite" name="sale_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/sale/static/tests/sales_team_dashboard_tests.js"></script>
<script type="text/javascript" src="/sale/static/tests/product_configurator.test.js"></script>
</xpath>
</template>
</odoo>
@@ -18,7 +18,7 @@
<div class="o_setting_right_pane">
<label for="group_product_variant"/>
<div class="text-muted">
Configure your products with variants and select optional products
Set product attributes (e.g. color, size) to sell variants
</div>
<div class="content-group" attrs="{'invisible': [('group_product_variant','=',False)]}">
<div class="mt16">
@@ -27,6 +27,17 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_sale_product_configurator"/>
</div>
<div class="o_setting_right_pane">
<label for="module_sale_product_configurator"/>
<div class="text-muted">
Configure your attributes with exclusions and select optional products
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="group_uom"/>
-7
View File
@@ -81,13 +81,6 @@
<xpath expr="//page[@name='sales']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//group[@name='sale']" position="after">
<group name="options" groups="product.group_product_variant">
<group string="Options">
<field name="optional_product_ids" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('id', '!=', active_id)]" />
</group>
</group>
</xpath>
</field>
</record>
+108
View File
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="variants">
<t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions(parent_combination, parent_name)"/>
<ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)">
<t t-foreach="product._get_valid_product_template_attribute_lines()" t-as="variant_id">
<!-- Attributes selection is hidden if there is only one value available and it's not a custom value -->
<li t-att-data-attribute_id="variant_id.attribute_id.id"
t-att-data-attribute_name="variant_id.attribute_id.name"
t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}">
<strong t-field="variant_id.attribute_id.name" class="attribute_name"/>
<t t-if="variant_id.attribute_id.type == 'select'">
<select
t-att-data-attribute_id="variant_id.attribute_id.id"
t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<option t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom"
t-att-selected="first_possible_combination[variant_id_index] == value_id">
<span t-field="value_id.name"/>
<span t-if="value_id.price_extra" class="badge badge-pill badge-secondary">
<!--
price_extra is displayed as catalog price instead of
price after pricelist because it is impossible to
compute. Indeed, the pricelist rule might depend on the
selected variant, so the price_extra will be different
depending on the selected combination. The price of an
attribute is therefore variable and it's not very
accurate to display it.
-->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</option>
</t>
</select>
</t>
<t t-if="variant_id.attribute_id.type == 'radio'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled">
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<li class="form-group js_attribute_value" style="margin: 0;">
<label class="col-form-label">
<div>
<input type="radio"
t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom" />
<div class="radio_input_value">
<span t-field="value_id.name"/>
<span class="badge badge-pill badge-secondary" t-if="value_id.price_extra">
<!-- see note above about price_extra -->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</div>
</div>
</label>
</li>
</t>
</ul>
</t>
<t t-if="variant_id.attribute_id.type == 'color'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline">
<li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item">
<label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}"
t-attf-class="css_attribute_color #{'active' if first_possible_combination[variant_id_index] == value_id else ''} #{'custom_value' if value_id.is_custom else ''}">
<input type="radio"
t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-title="value_id.name"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom"/>
</label>
</li>
</ul>
</t>
</li>
</t>
</ul>
</template>
</odoo>
-1
View File
@@ -3,4 +3,3 @@
from . import payment_acquirer_onboarding_wizard
from . import sale_make_invoice_advance
from . import sale_product_configurator
-1
View File
@@ -47,7 +47,6 @@ The Dashboard for the Sales Manager will include
'views/sale_management_views.xml',
'views/digest_views.xml',
'views/sale_order_views.xml',
'views/assets.xml',
'report/sale_report_templates.xml',
],
'demo': [
@@ -7,119 +7,3 @@ class TestUi(odoo.tests.HttpCase):
def test_01_sale_tour(self):
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_tour.ready", login="admin")
def test_02_product_configurator(self):
# group_product_variant: use the product configurator
# group_sale_pricelist: display the pricelist to determine when it is changed after choosing
# the partner
self.env.ref('base.user_admin').write({
'groups_id': [
(4, self.env.ref('product.group_product_variant').id),
(4, self.env.ref('product.group_sale_pricelist').id),
],
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_tour.ready", login="admin")
def test_03_product_configurator_advanced(self):
# group_product_variant: use the product configurator
# group_sale_pricelist: display the pricelist to determine when it is changed after choosing
# the partner
self.env.ref('base.user_admin').write({
'groups_id': [
(4, self.env.ref('product.group_product_variant').id),
(4, self.env.ref('product.group_sale_pricelist').id),
],
})
# Prepare relevant test data
# This is not included in demo data to avoid useless noise
product_attributes = self.env['product.attribute'].create([{
'name': 'PA1',
'type': 'radio',
'create_variant': 'dynamic'
}, {
'name': 'PA2',
'type': 'radio',
'create_variant': 'always'
}, {
'name': 'PA3',
'type': 'radio',
'create_variant': 'dynamic'
}, {
'name': 'PA4',
'type': 'select',
'create_variant': 'no_variant'
}, {
'name': 'PA5',
'type': 'select',
'create_variant': 'no_variant'
}, {
'name': 'PA7',
'type': 'color',
'create_variant': 'no_variant'
}, {
'name': 'PA8',
'type': 'radio',
'create_variant': 'no_variant'
}])
product_attribute_values = self.env['product.attribute.value'].create([{
'name': 'PAV' + str(i),
'is_custom': i == 9,
'attribute_id': product_attribute.id
} for i in range(1, 11) for product_attribute in product_attributes])
product_template_attribute_lines = self.env['product.template.attribute.line'].create([{
'attribute_id': product_attribute.id,
'product_tmpl_id': self.env.ref("product.product_product_4").id,
'value_ids': [(6, 0, product_attribute_values.filtered(
lambda product_attribute_value: product_attribute_value.attribute_id == product_attribute
).ids)]
} for product_attribute in product_attributes])
self.env.ref("product.product_product_4").update({
'attribute_line_ids': [(4, product_template_attribute_line.id) for product_template_attribute_line in product_template_attribute_lines]
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_advanced_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_advanced_tour.ready", login="admin")
def test_04_product_configurator_pricelist(self):
"""The goal of this test is to make sure pricelist rules are correctly
applied on the backend product configurator.
Also testing B2C setting: no impact on the backend configurator.
"""
admin = self.env.ref('base.user_admin')
# Activate B2C
self.env.ref('account.group_show_line_subtotals_tax_included').users |= admin
self.env.ref('account.group_show_line_subtotals_tax_excluded').users -= admin
# Active pricelist on SO
self.env.ref('product.group_sale_pricelist').users |= admin
# Add a 15% tax on desk
tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 15})
self.env.ref('product.product_product_4_product_template').taxes_id = tax
# Remove tax from Conference Chair and Chair floor protection
self.env.ref('sale.product_product_1_product_template').taxes_id = None
self.env.ref('product.product_product_11_product_template').taxes_id = None
# Make sure pricelist rule exist
product_template = self.env.ref('product.product_product_4_product_template')
pricelist = self.env.ref('product.list0')
if not pricelist.item_ids.filtered(lambda i: i.product_tmpl_id == product_template and i.price_discount == 20):
self.env['product.pricelist.item'].create({
'base': 'list_price',
'applied_on': '1_product',
'pricelist_id': pricelist.id,
'product_tmpl_id': product_template.id,
'price_discount': 20,
'min_quantity': 2,
'compute_price': 'formula',
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_pricelist_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_pricelist_tour.ready", login="admin")
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend_inherit_sale_management" inherit_id="web.assets_backend" name="Sale management backend assets">
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_test_ui.js"/>
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_advanced_test_ui.js"/>
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_pricelist_test_ui.js"/>
</xpath>
</template>
</odoo>
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
from . import wizard
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Sale Product Configurator",
'version': '1.0',
'category': 'Hidden',
'summary': "Configure your products",
'description': """
Technical module installed when the user checks the "module_sale_product_configurator" setting.
The main purpose is to override the sale_order view to allow configuring products in the SO form.
It also enables the optional products feature.
""",
'depends': ['sale'],
'data': [
'views/assets.xml',
'views/templates.xml',
'views/sale_views.xml',
'wizard/sale_product_configurator_views.xml',
],
'demo': [
'data/sale_demo.xml',
],
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
@@ -6,7 +6,7 @@ from odoo.http import request
class ProductConfiguratorController(http.Controller):
@http.route(['/product_configurator/configure'], type='json', auth="user", methods=['POST'])
@http.route(['/sale_product_configurator/configure'], type='json', auth="user", methods=['POST'])
def configure(self, product_id, pricelist_id, **kw):
add_qty = int(kw.get('add_qty', 1))
product_template = request.env['product.template'].browse(int(product_id))
@@ -17,39 +17,24 @@ class ProductConfiguratorController(http.Controller):
product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
to_currency = pricelist.currency_id
return request.env['ir.ui.view'].render_template("sale.product_configurator_configure", {
return request.env['ir.ui.view'].render_template("sale_product_configurator.configure", {
'product': product_template,
# to_currency deprecated, get it from the pricelist or product directly
'to_currency': to_currency,
'pricelist': pricelist,
'add_qty': add_qty,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions
})
@http.route(['/product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
@http.route(['/sale_product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
def show_optional_products(self, product_id, variant_values, pricelist_id, **kw):
pricelist = self._get_pricelist(pricelist_id)
return self._show_optional_products(product_id, variant_values, pricelist, False, **kw)
@http.route(['/product_configurator/optional_product_items'], type='json', auth="user", methods=['POST'])
@http.route(['/sale_product_configurator/optional_product_items'], type='json', auth="user", methods=['POST'])
def optional_product_items(self, product_id, pricelist_id, **kw):
pricelist = self._get_pricelist(pricelist_id)
return self._optional_product_items(product_id, pricelist, **kw)
@http.route(['/product_configurator/get_combination_info'], type='json', auth="user", methods=['POST'])
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
ProductTemplate = request.env['product.template']
if 'context' in kw:
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
return ProductTemplate.browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
@http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(product_template_attribute_value_ids)
def _optional_product_items(self, product_id, pricelist, **kw):
add_qty = int(kw.get('add_qty', 1))
product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
@@ -64,7 +49,7 @@ class ProductConfiguratorController(http.Controller):
# They are kept in the context since they are not linked to this product variant
parent_combination |= product.env.context.get('no_variant_attribute_values')
return request.env['ir.ui.view'].render_template("sale.optional_product_items", {
return request.env['ir.ui.view'].render_template("sale_product_configurator.optional_product_items", {
# product deprecated, it's not used in the view
'product': product,
# reference_product deprecated, use parent_combination instead
@@ -74,8 +59,6 @@ class ProductConfiguratorController(http.Controller):
'pricelist': pricelist,
# to_currency deprecated, get from pricelist or product
'to_currency': to_currency,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
'add_qty': add_qty,
})
@@ -101,7 +84,7 @@ class ProductConfiguratorController(http.Controller):
if no_variant_attribute_values:
product = product.with_context(no_variant_attribute_values=no_variant_attribute_values)
return request.env['ir.ui.view'].render_template("sale.optional_products_modal", {
return request.env['ir.ui.view'].render_template("sale_product_configurator.optional_products_modal", {
'product': product,
'combination': combination,
'add_qty': add_qty,
@@ -115,21 +98,8 @@ class ProductConfiguratorController(http.Controller):
# to_currency deprecated, get from pricelist or product
'to_currency': to_currency,
'handle_stock': handle_stock,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
})
def _get_attribute_exclusions(self, product, reference_product=None):
"""deprecated, use product method"""
parent_combination = request.env['product.template.attribute.value']
if reference_product:
parent_combination |= reference_product.product_template_attribute_value_ids
if reference_product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
parent_combination |= reference_product.env.context.get('no_variant_attribute_values')
return product._get_attribute_exclusions(parent_combination, reference_product.name if reference_product else None)
def _get_product_context(self, pricelist=None, **kw):
"""deprecated, can be removed in master"""
product_context = dict(request.context)
@@ -140,10 +110,5 @@ class ProductConfiguratorController(http.Controller):
return product_context
def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw):
"""deprecated, use product method"""
combination = request.env['product.template.attribute.value'].browse(combination)
return request.env['product.template'].browse(product_template_id)._get_combination_info(combination, product_id, add_qty, pricelist)
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
return request.env['product.pricelist'].browse(int(pricelist_id or 0))
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="product_product_1_product_template" model="product.template">
<field name="name">Chair floor protection</field>
<field name="categ_id" ref="product.product_category_5"/>
<field name="list_price">12.0</field>
<field name="weight">0.01</field>
<field name="uom_id" ref="uom.product_uom_unit"/>
<field name="uom_po_id" ref="uom.product_uom_unit"/>
<field name="description_sale">Office chairs can harm your floor: protect it.</field>
<field name="image" type="base64" file="sale/static/img/floor_protection-image.png"/>
</record>
<record id="product.product_product_4_product_template" model="product.template">
<field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/>
</record>
<record id="product.product_product_11_product_template" model="product.template">
<field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/>
</record>
<record id="product.product_product_13_product_template" model="product.template">
<field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/>
</record>
</data>
</odoo>
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
optional_product_ids = fields.Many2many(
'product.template', 'product_optional_rel', 'src_id', 'dest_id',
string='Optional Products', help="Optional Products are suggested "
"whenever the customer hits *Add to Cart* (cross-sell strategy, "
"e.g. for computers: warranty, software, etc.).")
@@ -1,10 +1,10 @@
odoo.define('sale.ProductConfiguratorFormController', function (require) {
odoo.define('sale_product_configurator.ProductConfiguratorFormController', function (require) {
"use strict";
var core = require('web.core');
var _t = core._t;
var FormController = require('web.FormController');
var OptionalProductsModal = require('sale.OptionalProductsModal');
var OptionalProductsModal = require('sale_product_configurator.OptionalProductsModal');
var ProductConfiguratorFormController = FormController.extend({
custom_events: _.extend({}, FormController.prototype.custom_events, {
@@ -127,8 +127,7 @@ var ProductConfiguratorFormController = FormController.extend({
pricelistId: self.renderer.pricelistId,
okButtonText: _t('Confirm'),
cancelButtonText: _t('Back'),
title: _t('Configure'),
context: self.initialState.context,
title: _t('Configure')
}).open();
self.optionalProductsModal.on('options_empty', null,
@@ -1,4 +1,4 @@
odoo.define('sale.OptionalProductsModal', function (require) {
odoo.define('sale_product_configurator.OptionalProductsModal', function (require) {
"use strict";
var ajax = require('web.ajax');
@@ -79,7 +79,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
willStart: function () {
var self = this;
var uri = this._getUri("/product_configurator/show_optional_products");
var uri = this._getUri("/sale_product_configurator/show_optional_products");
var getModalContent = ajax.jsonRpc(uri, 'call', {
product_id: self.rootProduct.product_id,
variant_values: self.rootProduct.variant_values,
@@ -361,7 +361,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
).then(function (productId) {
$parent.find('.product_id').val(productId);
ajax.jsonRpc(self._getUri("/product_configurator/optional_product_items"), 'call', {
ajax.jsonRpc(self._getUri("/sale_product_configurator/optional_product_items"), 'call', {
'product_id': productId,
'pricelist_id': self.pricelistId || false,
}).then(function (addedItem) {
@@ -1,4 +1,4 @@
odoo.define('sale.ProductConfiguratorFormRenderer', function (require) {
odoo.define('sale_product_configurator.ProductConfiguratorFormRenderer', function (require) {
"use strict";
var FormRenderer = require('web.FormRenderer');
@@ -1,8 +1,8 @@
odoo.define('sale.ProductConfiguratorFormView', function (require) {
odoo.define('sale_product_configurator.ProductConfiguratorFormView', function (require) {
"use strict";
var ProductConfiguratorFormController = require('sale.ProductConfiguratorFormController');
var ProductConfiguratorFormRenderer = require('sale.ProductConfiguratorFormRenderer');
var ProductConfiguratorFormController = require('sale_product_configurator.ProductConfiguratorFormController');
var ProductConfiguratorFormRenderer = require('sale_product_configurator.ProductConfiguratorFormRenderer');
var FormView = require('web.FormView');
var viewRegistry = require('web.view_registry');
@@ -2,7 +2,7 @@ odoo.define('sale.product.configurator.tests', function (require) {
"use strict";
var FormView = require('web.FormView');
var ProductConfiguratorFormView = require('sale.ProductConfiguratorFormView');
var ProductConfiguratorFormView = require('sale_product_configurator.ProductConfiguratorFormView');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_sale_product_configurator_ui
@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
@odoo.tests.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
def test_01_product_configurator(self):
# To be able to test the product configurator, admin user must have access to "variants and options" feature, so we give him the right group for that
self.env.ref('base.user_admin').write({'groups_id': [(4, self.env.ref('product.group_product_variant').id)]})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_tour.ready", login="admin")
def test_02_product_configurator_advanced(self):
# group_product_variant: use the product configurator
# group_sale_pricelist: display the pricelist to determine when it is changed after choosing
# the partner
self.env.ref('base.user_admin').write({
'groups_id': [
(4, self.env.ref('product.group_product_variant').id),
(4, self.env.ref('product.group_sale_pricelist').id),
],
})
# Prepare relevant test data
# This is not included in demo data to avoid useless noise
product_attributes = self.env['product.attribute'].create([{
'name': 'PA1',
'type': 'radio',
'create_variant': 'dynamic'
}, {
'name': 'PA2',
'type': 'radio',
'create_variant': 'always'
}, {
'name': 'PA3',
'type': 'radio',
'create_variant': 'dynamic'
}, {
'name': 'PA4',
'type': 'select',
'create_variant': 'no_variant'
}, {
'name': 'PA5',
'type': 'select',
'create_variant': 'no_variant'
}, {
'name': 'PA7',
'type': 'color',
'create_variant': 'no_variant'
}, {
'name': 'PA8',
'type': 'radio',
'create_variant': 'no_variant'
}])
product_attribute_values = self.env['product.attribute.value'].create([{
'name': 'PAV' + str(i),
'is_custom': i == 9,
'attribute_id': product_attribute.id
} for i in range(1, 11) for product_attribute in product_attributes])
product_template_attribute_lines = self.env['product.template.attribute.line'].create([{
'attribute_id': product_attribute.id,
'product_tmpl_id': self.env.ref("product.product_product_4").id,
'value_ids': [(6, 0, product_attribute_values.filtered(
lambda product_attribute_value: product_attribute_value.attribute_id == product_attribute
).ids)]
} for product_attribute in product_attributes])
self.env.ref("product.product_product_4").update({
'attribute_line_ids': [(4, product_template_attribute_line.id) for product_template_attribute_line in product_template_attribute_lines]
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_advanced_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_advanced_tour.ready", login="admin")
def test_03_product_configurator_edition(self):
# To be able to test the product configurator, admin user must have access to "variants and options" feature, so we give him the right group for that
self.env.ref('base.user_admin').write({'groups_id': [(4, self.env.ref('product.group_product_variant').id)]})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_edition_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_edition_tour.ready", login="admin")
def test_04_product_configurator_single_custom_value(self):
# group_product_variant: use the product configurator
# group_sale_pricelist: display the pricelist to determine when it is changed after choosing
# the partner
self.env.ref('base.user_admin').write({
'groups_id': [
(4, self.env.ref('product.group_product_variant').id),
(4, self.env.ref('product.group_sale_pricelist').id),
],
})
# Prepare relevant test data
# This is not included in demo data to avoid useless noise
product_attributes = self.env['product.attribute'].create([{
'name': 'product attribute',
'type': 'radio',
'create_variant': 'always'
}])
product_attribute_values = self.env['product.attribute.value'].create([{
'name': 'single product attribute value',
'is_custom': True,
'attribute_id': product_attributes[0].id
}])
product_template_attribute_lines = self.env['product.template.attribute.line'].create([{
'attribute_id': product_attributes[0].id,
'product_tmpl_id': self.env.ref("product.product_product_4").id,
'value_ids': [(6, 0, [product_attribute_values[0].id])]
}])
self.env.ref("product.product_product_4").update({
'attribute_line_ids': [(4, product_template_attribute_lines[0].id)]
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_single_custom_attribute_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_single_custom_attribute_tour.ready", login="admin")
def test_05_product_configurator_pricelist(self):
"""The goal of this test is to make sure pricelist rules are correctly
applied on the backend product configurator.
Also testing B2C setting: no impact on the backend configurator.
"""
admin = self.env.ref('base.user_admin')
# Activate B2C
self.env.ref('account.group_show_line_subtotals_tax_included').users |= admin
self.env.ref('account.group_show_line_subtotals_tax_excluded').users -= admin
# Active pricelist on SO
self.env.ref('product.group_sale_pricelist').users |= admin
# Add a 15% tax on desk
tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 15})
self.env.ref('product.product_product_4_product_template').taxes_id = tax
# Remove tax from Conference Chair and Chair floor protection
self.env.ref('sale_product_configurator.product_product_1_product_template').taxes_id = None
self.env.ref('product.product_product_11_product_template').taxes_id = None
# Make sure pricelist rule exist
product_template = self.env.ref('product.product_product_4_product_template')
pricelist = self.env.ref('product.list0')
if not pricelist.item_ids.filtered(lambda i: i.product_tmpl_id == product_template and i.price_discount == 20):
self.env['product.pricelist.item'].create({
'base': 'list_price',
'applied_on': '1_product',
'pricelist_id': pricelist.id,
'product_tmpl_id': product_template.id,
'price_discount': 20,
'min_quantity': 2,
'compute_price': 'formula',
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_pricelist_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_pricelist_tour.ready", login="admin")
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend" name="sale_product_configurator assets backend">
<xpath expr="link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/sale_product_configurator/static/src/scss/product_configurator_widget.scss"/>
</xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale_product_configurator/static/src/js/tour.js"></script>
<script type="text/javascript" src="/sale/static/src/js/product_configurator_mixin.js"></script>
<script type="text/javascript" src="/sale_product_configurator/static/src/js/product_configurator_renderer.js"></script>
<script type="text/javascript" src="/sale_product_configurator/static/src/js/product_configurator_controller.js"></script>
<script type="text/javascript" src="/sale_product_configurator/static/src/js/product_configurator_view.js"></script>
<script type="text/javascript" src="/sale_product_configurator/static/src/js/product_configurator_widget.js"/>
<script type="text/javascript" src="/sale_product_configurator/static/src/js/product_configurator_modal.js"></script>
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_test_ui.js"/>
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_advanced_test_ui.js"/>
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_edition_test_ui.js"/>
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_single_custom_attribute_test_ui.js"/>
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator_pricelist_test_ui.js"/>
</xpath>
</template>
<template id="qunit_suite" inherit_id="web.qunit_suite" name="sale_product_configurator_tests">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/sale_product_configurator/static/tests/product_configurator.test.js"></script>
</xpath>
</template>
</odoo>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="product_template_view_form" model="ir.ui.view">
<field name="name">product.template.form.inherit.sale.product.configurator</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='sale']" position="after">
<group name="options" groups="product.group_product_variant">
<group string="Options">
<field name="optional_product_ids" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('id', '!=', active_id)]" />
</group>
</group>
</xpath>
</field>
</record>
</odoo>
@@ -2,12 +2,12 @@
<odoo>
<template id="optional_products_modal" name="Optional Products">
<main class="modal-body">
<t t-call="sale.product_configurator_configure_optional_products" />
<t t-call="sale_product_configurator.configure_optional_products" />
</main>
</template>
<!-- backend -->
<template id="product_configurator_configure" name="Configure">
<template id="configure" name="Configure">
<div class="js_product main_product">
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
@@ -64,7 +64,7 @@
</template>
<!-- modal: full table, currenclty selected products at top -->
<template id="product_configurator_configure_optional_products">
<template id="configure_optional_products">
<table class="table table-striped table-sm">
<thead>
<tr>
@@ -141,7 +141,7 @@
</td>
</tr>
<tr t-if="product.optional_product_ids" class="o_select_options"><td colspan="4"><h4>Available Options:</h4></td></tr>
<t t-call="sale.optional_product_items">
<t t-call="sale_product_configurator.optional_product_items">
<t t-set="parent_combination" t-value="combination"/>
</t>
</tbody>
@@ -201,112 +201,4 @@
</t>
</t>
</template>
<template id="variants">
<t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions(parent_combination, parent_name)"/>
<ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)">
<t t-foreach="product._get_valid_product_template_attribute_lines()" t-as="variant_id">
<!-- Attributes selection is hidden if there is only one value available and it's not a custom value -->
<li t-att-data-attribute_id="variant_id.attribute_id.id"
t-att-data-attribute_name="variant_id.attribute_id.name"
t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}">
<strong t-field="variant_id.attribute_id.name" class="attribute_name"/>
<t t-if="variant_id.attribute_id.type == 'select'">
<select
t-att-data-attribute_id="variant_id.attribute_id.id"
t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<option t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom"
t-att-selected="first_possible_combination[variant_id_index] == value_id">
<span t-field="value_id.name"/>
<span t-if="value_id.price_extra" class="badge badge-pill badge-secondary">
<!--
price_extra is displayed as catalog price instead of
price after pricelist because it is impossible to
compute. Indeed, the pricelist rule might depend on the
selected variant, so the price_extra will be different
depending on the selected combination. The price of an
attribute is therefore variable and it's not very
accurate to display it.
-->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</option>
</t>
</select>
</t>
<t t-if="variant_id.attribute_id.type == 'radio'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled">
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<li class="form-group js_attribute_value" style="margin: 0;">
<label class="col-form-label">
<div>
<input type="radio"
t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom" />
<div class="radio_input_value">
<span t-field="value_id.name"/>
<span class="badge badge-pill badge-secondary" t-if="value_id.price_extra">
<!-- see note above about price_extra -->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</div>
</div>
</label>
</li>
</t>
</ul>
</t>
<t t-if="variant_id.attribute_id.type == 'color'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline">
<li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item">
<label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}"
t-attf-class="css_attribute_color #{'active' if first_possible_combination[variant_id_index] == value_id else ''} #{'custom_value' if value_id.is_custom else ''}">
<input type="radio"
t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-title="value_id.name"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom"/>
</label>
</li>
</ul>
</t>
</li>
</t>
</ul>
</template>
</odoo>
@@ -15,4 +15,4 @@
</form>
</field>
</record>
</odoo>
</odoo>
@@ -25,6 +25,17 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_sale_product_configurator"/>
</div>
<div class="o_setting_right_pane">
<label for="module_sale_product_configurator"/>
<div class="text-muted">
Configure your attributes with exclusions and select optional products
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="digital_content_setting" title="Provide customers with product-specific links or downloadable content in the confirmation page of the checkout process if the payment gets through. To do so, attach some files to a product using the new Files button and publish them.">
<div class="o_setting_left_pane">
<field name="module_website_sale_digital"/>