summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAurélien Warnon <awa@odoo.com>2019-03-19 13:40:48 +0000
committerAurélien Warnon <awa@odoo.com>2019-03-27 15:12:50 +0000
commit3cb46ec74ca8e8d96b358ccce3afc32ff4986bf0 (patch)
tree22e1e5ad40714f69e3bd708f11877bad27971894
parent14f0e81e20c05bef6223fc9ae14de35f132486ab (diff)
[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)
-rw-r--r--addons/product/models/res_config_settings.py14
-rw-r--r--addons/sale/__manifest__.py3
-rw-r--r--addons/sale/controllers/__init__.py2
-rw-r--r--addons/sale/controllers/variant.py28
-rw-r--r--addons/sale/data/sale_demo.xml17
-rw-r--r--addons/sale/models/product_product.py10
-rw-r--r--addons/sale/static/src/js/product_configurator_mixin.js4
-rw-r--r--addons/sale/views/assets.xml6
-rw-r--r--addons/sale/views/res_config_settings_views.xml13
-rw-r--r--addons/sale/views/sale_views.xml7
-rw-r--r--addons/sale/views/variant_templates.xml108
-rw-r--r--addons/sale/wizard/__init__.py1
-rw-r--r--addons/sale_management/__manifest__.py1
-rw-r--r--addons/sale_management/tests/test_sale_ui.py116
-rw-r--r--addons/sale_management/views/assets.xml10
-rw-r--r--addons/sale_product_configurator/__init__.py5
-rw-r--r--addons/sale_product_configurator/__manifest__.py26
-rw-r--r--addons/sale_product_configurator/controllers/__init__.py4
-rw-r--r--addons/sale_product_configurator/controllers/main.py (renamed from addons/sale/controllers/product_configurator.py)47
-rw-r--r--addons/sale_product_configurator/data/sale_demo.xml25
-rw-r--r--addons/sale_product_configurator/models/product.py14
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_controller.js (renamed from addons/sale/static/src/js/product_configurator_controller.js)7
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_modal.js (renamed from addons/sale/static/src/js/product_configurator_modal.js)6
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_renderer.js (renamed from addons/sale/static/src/js/product_configurator_renderer.js)2
-rw-r--r--addons/sale_product_configurator/static/src/js/product_configurator_view.js (renamed from addons/sale/static/src/js/product_configurator_view.js)6
-rw-r--r--addons/sale_product_configurator/static/tests/product_configurator.test.js (renamed from addons/sale/static/tests/product_configurator.test.js)2
-rw-r--r--addons/sale_product_configurator/static/tests/product_configurator_advanced_test_ui.js (renamed from addons/sale_management/static/tests/product_configurator_advanced_test_ui.js)0
-rw-r--r--addons/sale_product_configurator/static/tests/product_configurator_pricelist_test_ui.js (renamed from addons/sale_management/static/tests/product_configurator_pricelist_test_ui.js)0
-rw-r--r--addons/sale_product_configurator/static/tests/product_configurator_test_ui.js (renamed from addons/sale_management/static/tests/product_configurator_test_ui.js)0
-rw-r--r--addons/sale_product_configurator/tests/__init__.py4
-rw-r--r--addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py159
-rw-r--r--addons/sale_product_configurator/views/assets.xml27
-rw-r--r--addons/sale_product_configurator/views/sale_views.xml17
-rw-r--r--addons/sale_product_configurator/views/templates.xml (renamed from addons/sale/views/sale_product_configurator_templates.xml)116
-rw-r--r--addons/sale_product_configurator/wizard/sale_product_configurator.py (renamed from addons/sale/wizard/sale_product_configurator.py)0
-rw-r--r--addons/sale_product_configurator/wizard/sale_product_configurator_views.xml (renamed from addons/sale/wizard/sale_product_configurator_views.xml)2
-rw-r--r--addons/website_sale/views/res_config_settings_views.xml11
37 files changed, 480 insertions, 340 deletions
diff --git a/addons/product/models/res_config_settings.py b/addons/product/models/res_config_settings.py
index 7e73f4df5e6..6272b1f37ba 100644
--- a/addons/product/models/res_config_settings.py
+++ b/addons/product/models/res_config_settings.py
@@ -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):
diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py
index 741d357c900..c275df0ac9b 100644
--- a/addons/sale/__manifest__.py
+++ b/addons/sale/__manifest__.py
@@ -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',
diff --git a/addons/sale/controllers/__init__.py b/addons/sale/controllers/__init__.py
index ac5f1269f82..a35a880227c 100644
--- a/addons/sale/controllers/__init__.py
+++ b/addons/sale/controllers/__init__.py
@@ -3,4 +3,4 @@
from . import onboarding
from . import portal
-from . import product_configurator
+from . import variant
diff --git a/addons/sale/controllers/variant.py b/addons/sale/controllers/variant.py
new file mode 100644
index 00000000000..3ca2c7c32b0
--- /dev/null
+++ b/addons/sale/controllers/variant.py
@@ -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)
diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml
index d0ccf291eff..d83063b9158 100644
--- a/addons/sale/data/sale_demo.xml
+++ b/addons/sale/data/sale_demo.xml
@@ -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"/>
diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py
index a489b03b952..8d919e39ac3 100644
--- a/addons/sale/models/product_product.py
+++ b/addons/sale/models/product_product.py
@@ -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'
diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js
index 80471814207..7e7847215a7 100644
--- a/addons/sale/static/src/js/product_configurator_mixin.js
+++ b/addons/sale/static/src/js/product_configurator_mixin.js
@@ -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 {
diff --git a/addons/sale/views/assets.xml b/addons/sale/views/assets.xml
index ea5c82b1c86..9e7e42eff80 100644
--- a/addons/sale/views/assets.xml
+++ b/addons/sale/views/assets.xml
@@ -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>
diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml
index bc1366e5d5c..3f2bb403453 100644
--- a/addons/sale/views/res_config_settings_views.xml
+++ b/addons/sale/views/res_config_settings_views.xml
@@ -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">
@@ -29,6 +29,17 @@
</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"/>
</div>
<div class="o_setting_right_pane">
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index bcf728e356b..e793650ecb6 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -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>
diff --git a/addons/sale/views/variant_templates.xml b/addons/sale/views/variant_templates.xml
new file mode 100644
index 00000000000..8d52d860be4
--- /dev/null
+++ b/addons/sale/views/variant_templates.xml
@@ -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>
diff --git a/addons/sale/wizard/__init__.py b/addons/sale/wizard/__init__.py
index 145cce89c1e..09f069f85ed 100644
--- a/addons/sale/wizard/__init__.py
+++ b/addons/sale/wizard/__init__.py
@@ -3,4 +3,3 @@
from . import payment_acquirer_onboarding_wizard
from . import sale_make_invoice_advance
-from . import sale_product_configurator \ No newline at end of file
diff --git a/addons/sale_management/__manifest__.py b/addons/sale_management/__manifest__.py
index 3bf801a604b..98f77d20956 100644
--- a/addons/sale_management/__manifest__.py
+++ b/addons/sale_management/__manifest__.py
@@ -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': [
diff --git a/addons/sale_management/tests/test_sale_ui.py b/addons/sale_management/tests/test_sale_ui.py
index 6ec2f4167c1..cf107b02ebc 100644
--- a/addons/sale_management/tests/test_sale_ui.py
+++ b/addons/sale_management/tests/test_sale_ui.py
@@ -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")
diff --git a/addons/sale_management/views/assets.xml b/addons/sale_management/views/assets.xml
deleted file mode 100644
index a5a3134949d..00000000000
--- a/addons/sale_management/views/assets.xml
+++ /dev/null
@@ -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>
diff --git a/addons/sale_product_configurator/__init__.py b/addons/sale_product_configurator/__init__.py
new file mode 100644
index 00000000000..1f4530082b3
--- /dev/null
+++ b/addons/sale_product_configurator/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
+from . import wizard
diff --git a/addons/sale_product_configurator/__manifest__.py b/addons/sale_product_configurator/__manifest__.py
new file mode 100644
index 00000000000..fd72a2fec6f
--- /dev/null
+++ b/addons/sale_product_configurator/__manifest__.py
@@ -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',
+ ],
+}
diff --git a/addons/sale_product_configurator/controllers/__init__.py b/addons/sale_product_configurator/controllers/__init__.py
new file mode 100644
index 00000000000..5d4b25db9c0
--- /dev/null
+++ b/addons/sale_product_configurator/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import main
diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale_product_configurator/controllers/main.py
index 8ebcf879ba8..d991c4ff277 100644
--- a/addons/sale/controllers/product_configurator.py
+++ b/addons/sale_product_configurator/controllers/main.py
@@ -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))
diff --git a/addons/sale_product_configurator/data/sale_demo.xml b/addons/sale_product_configurator/data/sale_demo.xml
new file mode 100644
index 00000000000..00683af8855
--- /dev/null
+++ b/addons/sale_product_configurator/data/sale_demo.xml
@@ -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>
diff --git a/addons/sale_product_configurator/models/product.py b/addons/sale_product_configurator/models/product.py
new file mode 100644
index 00000000000..a47d6d4421a
--- /dev/null
+++ b/addons/sale_product_configurator/models/product.py
@@ -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.).")
diff --git a/addons/sale/static/src/js/product_configurator_controller.js b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js
index 7ca3d32c292..a7411a77370 100644
--- a/addons/sale/static/src/js/product_configurator_controller.js
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_controller.js
@@ -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,
diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale_product_configurator/static/src/js/product_configurator_modal.js
index 3bcebf88725..9fc78b9528f 100644
--- a/addons/sale/static/src/js/product_configurator_modal.js
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_modal.js
@@ -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) {
diff --git a/addons/sale/static/src/js/product_configurator_renderer.js b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js
index f3f437461ea..62a7d7ea06b 100644
--- a/addons/sale/static/src/js/product_configurator_renderer.js
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js
@@ -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');
diff --git a/addons/sale/static/src/js/product_configurator_view.js b/addons/sale_product_configurator/static/src/js/product_configurator_view.js
index 5a3bb4401be..d3294deb42d 100644
--- a/addons/sale/static/src/js/product_configurator_view.js
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_view.js
@@ -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');
diff --git a/addons/sale/static/tests/product_configurator.test.js b/addons/sale_product_configurator/static/tests/product_configurator.test.js
index c751eb6c111..e39fa64644d 100644
--- a/addons/sale/static/tests/product_configurator.test.js
+++ b/addons/sale_product_configurator/static/tests/product_configurator.test.js
@@ -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;
diff --git a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js b/addons/sale_product_configurator/static/tests/product_configurator_advanced_test_ui.js
index 074da95f06e..074da95f06e 100644
--- a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js
+++ b/addons/sale_product_configurator/static/tests/product_configurator_advanced_test_ui.js
diff --git a/addons/sale_management/static/tests/product_configurator_pricelist_test_ui.js b/addons/sale_product_configurator/static/tests/product_configurator_pricelist_test_ui.js
index 0cb9b46e530..0cb9b46e530 100644
--- a/addons/sale_management/static/tests/product_configurator_pricelist_test_ui.js
+++ b/addons/sale_product_configurator/static/tests/product_configurator_pricelist_test_ui.js
diff --git a/addons/sale_management/static/tests/product_configurator_test_ui.js b/addons/sale_product_configurator/static/tests/product_configurator_test_ui.js
index 03f5a0a8bbc..03f5a0a8bbc 100644
--- a/addons/sale_management/static/tests/product_configurator_test_ui.js
+++ b/addons/sale_product_configurator/static/tests/product_configurator_test_ui.js
diff --git a/addons/sale_product_configurator/tests/__init__.py b/addons/sale_product_configurator/tests/__init__.py
new file mode 100644
index 00000000000..c1cb786c919
--- /dev/null
+++ b/addons/sale_product_configurator/tests/__init__.py
@@ -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
diff --git a/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py b/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py
new file mode 100644
index 00000000000..a37e5da7f8a
--- /dev/null
+++ b/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py
@@ -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") \ No newline at end of file
diff --git a/addons/sale_product_configurator/views/assets.xml b/addons/sale_product_configurator/views/assets.xml
new file mode 100644
index 00000000000..3e68f30d810
--- /dev/null
+++ b/addons/sale_product_configurator/views/assets.xml
@@ -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>
diff --git a/addons/sale_product_configurator/views/sale_views.xml b/addons/sale_product_configurator/views/sale_views.xml
new file mode 100644
index 00000000000..499cb2c7eb0
--- /dev/null
+++ b/addons/sale_product_configurator/views/sale_views.xml
@@ -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>
diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale_product_configurator/views/templates.xml
index 1f0ec29d534..534a199dc70 100644
--- a/addons/sale/views/sale_product_configurator_templates.xml
+++ b/addons/sale_product_configurator/views/templates.xml
@@ -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>
diff --git a/addons/sale/wizard/sale_product_configurator.py b/addons/sale_product_configurator/wizard/sale_product_configurator.py
index 75563872928..75563872928 100644
--- a/addons/sale/wizard/sale_product_configurator.py
+++ b/addons/sale_product_configurator/wizard/sale_product_configurator.py
diff --git a/addons/sale/wizard/sale_product_configurator_views.xml b/addons/sale_product_configurator/wizard/sale_product_configurator_views.xml
index 9b29c3f9183..a3d3eddfee9 100644
--- a/addons/sale/wizard/sale_product_configurator_views.xml
+++ b/addons/sale_product_configurator/wizard/sale_product_configurator_views.xml
@@ -15,4 +15,4 @@
</form>
</field>
</record>
-</odoo> \ No newline at end of file
+</odoo>
diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml
index 6a2daeee7da..9890566f03b 100644
--- a/addons/website_sale/views/res_config_settings_views.xml
+++ b/addons/website_sale/views/res_config_settings_views.xml
@@ -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"/>