[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:
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
|
||||
from . import onboarding
|
||||
from . import portal
|
||||
from . import product_configurator
|
||||
from . import variant
|
||||
|
||||
@@ -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)
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -3,4 +3,3 @@
|
||||
|
||||
from . import payment_acquirer_onboarding_wizard
|
||||
from . import sale_make_invoice_advance
|
||||
from . import sale_product_configurator
|
||||
@@ -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")
|
||||
|
||||
@@ -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
-41
@@ -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.).")
|
||||
+3
-4
@@ -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,
|
||||
+3
-3
@@ -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
-1
@@ -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');
|
||||
+3
-3
@@ -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');
|
||||
|
||||
+1
-1
@@ -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>
|
||||
+4
-112
@@ -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>
|
||||
+1
-1
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user