summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorAurélien Warnon <awa@odoo.com>2018-08-30 11:59:54 +0200
committerAurélien Warnon <awa@odoo.com>2018-09-25 18:39:32 +0200
commit856c2e9008f1af7bc1327d9c2900db1e109ab0fa (patch)
tree7ec14601948c0dab4607417c0043d75cd83c0549 /addons
parentfd07990b85cfc87ee0c9a8353bb6c966d403f6bf (diff)
[IMP] product,(website_)sale: Move product options configurator into sale module
Task #1871557 Purpose ======= - Configure a product from a sales order as easily as in the ecommerce The backend uses the same template as the frontend to configure a products and its options - Display optionnal products of optionnal products in the "sale options" section - Allow adding exlusions to some combinations of the product and/or to some combinations of the optionnal and accessory products Specification ============= PHASE I 1. Improve the "Add to cart" with optional products - When you add an option to cart, move the product to the cart & show the options of this option - Don't open the option wizard if no option available (in case this module becomes generic, it's better to skip this step when it's not needed) - Display the options right under their related product in the cart 2. Do not allow to add the product as an optional product for itself Why ? - No functional sense - Creates issues in the add to cart wizard 3. Exclude some attribute combinations within the product or with related options and accessory products - Rename VARIANT PRICES button -> CONFIGURE VARIANTS - When you click this button and select a value, open form view rather than inline edition - Form view of variant values: - Attribute - Value - HTML Color Index - Attribute Price Extra - Not Compatible with: o2m tab with 2 fields: Product AND Attribute Value (of this product) [m2m tag selection] - in any new line, autocomplete the current product by default - Tooltip: A list of product and attribute values that you want to exclude for this product's attribue value. Also applies on optionnal and accessory products. 4. New option to configure a product in a sales order line - This option is only visible if the corresponding option is activated in the sales settings - It opens a wizard to select a product template, configure related attribute values and select optional products (based on frontend view) - Attributes not compatible with other selected attributes should not be selectable - Must work like in the frontend
Diffstat (limited to 'addons')
-rw-r--r--addons/point_of_sale/tests/test_frontend.py5
-rw-r--r--addons/product/data/product_demo.xml38
-rw-r--r--addons/product/models/product.py19
-rw-r--r--addons/product/models/product_attribute.py147
-rw-r--r--addons/product/models/product_template.py53
-rw-r--r--addons/product/models/res_config_settings.py2
-rw-r--r--addons/product/security/ir.model.access.csv6
-rw-r--r--addons/product/tests/__init__.py6
-rw-r--r--addons/product/tests/test_product_attribute_value_config.py166
-rw-r--r--addons/product/views/product_attribute_views.xml174
-rw-r--r--addons/product/views/product_views.xml2
-rw-r--r--addons/sale/__manifest__.py5
-rw-r--r--addons/sale/controllers/__init__.py1
-rw-r--r--addons/sale/controllers/product_configurator.py138
-rw-r--r--addons/sale/data/sale_demo.xml80
-rw-r--r--addons/sale/models/product_product.py36
-rw-r--r--addons/sale/models/sale.py13
-rw-r--r--addons/sale/security/ir.model.access.csv3
-rw-r--r--addons/sale/static/img/floor_protection-image.pngbin0 -> 6378 bytes
-rw-r--r--addons/sale/static/src/js/product_configurator.js168
-rw-r--r--addons/sale/static/src/js/product_configurator_list_editable_renderer.js71
-rw-r--r--addons/sale/static/src/js/product_configurator_modal.js298
-rw-r--r--addons/sale/static/src/js/product_configurator_utils.js272
-rw-r--r--addons/sale/static/src/scss/product_configurator.scss178
-rw-r--r--addons/sale/static/tests/product_configurator.test.js140
-rw-r--r--addons/sale/views/assets.xml10
-rw-r--r--addons/sale/views/product_attribute_views.xml77
-rw-r--r--addons/sale/views/res_config_settings_views.xml2
-rw-r--r--addons/sale/views/sale_product_configurator_templates.xml256
-rw-r--r--addons/sale/views/sale_views.xml22
-rw-r--r--addons/sale/wizard/__init__.py1
-rw-r--r--addons/sale/wizard/sale_product_configurator.py12
-rw-r--r--addons/sale/wizard/sale_product_configurator_views.xml18
-rw-r--r--addons/sale_management/__manifest__.py2
-rw-r--r--addons/sale_management/static/tests/product_configurator_test_ui.js66
-rw-r--r--addons/sale_management/views/assets.xml8
-rw-r--r--addons/sale_management/views/sale_management_views.xml9
-rw-r--r--addons/sale_stock/__manifest__.py2
-rw-r--r--addons/sale_stock/models/sale_order.py4
-rw-r--r--addons/stock/security/ir.model.access.csv3
-rw-r--r--addons/web/static/src/js/fields/abstract_field.js1
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js10
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js6
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js2
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js2
-rw-r--r--addons/website_sale/controllers/main.py82
-rw-r--r--addons/website_sale/data/demo.xml59
-rw-r--r--addons/website_sale/models/product.py14
-rw-r--r--addons/website_sale/models/res_config_settings.py1
-rw-r--r--addons/website_sale/models/sale_order.py47
-rw-r--r--addons/website_sale/security/ir.model.access.csv3
-rw-r--r--addons/website_sale/static/src/js/website_sale.js164
-rw-r--r--addons/website_sale/static/src/js/website_sale_options.js88
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_buy.js4
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_customize.js216
-rw-r--r--addons/website_sale/static/src/scss/website_sale.scss125
-rw-r--r--addons/website_sale/static/src/scss/website_sale_options.scss (renamed from addons/website_sale_options/static/src/scss/website_sale.scss)7
-rw-r--r--addons/website_sale/tests/test_customize.py (renamed from addons/website_sale_options/tests/test_customize.py)4
-rw-r--r--addons/website_sale/views/product_views.xml62
-rw-r--r--addons/website_sale/views/res_config_settings_views.xml11
-rw-r--r--addons/website_sale/views/templates.xml97
-rw-r--r--addons/website_sale_comparison/views/website_sale_comparison_view.xml2
-rw-r--r--addons/website_sale_options/__init__.py5
-rw-r--r--addons/website_sale_options/__manifest__.py23
-rw-r--r--addons/website_sale_options/controllers/__init__.py4
-rw-r--r--addons/website_sale_options/controllers/main.py85
-rw-r--r--addons/website_sale_options/data/product_demo.xml38
-rw-r--r--addons/website_sale_options/models/__init__.py5
-rw-r--r--addons/website_sale_options/models/sale_order.py55
-rw-r--r--addons/website_sale_options/static/description/icon.pngbin8396 -> 0 bytes
-rw-r--r--addons/website_sale_options/static/description/icon.svg1
-rw-r--r--addons/website_sale_options/static/src/js/website_sale.test.js197
-rw-r--r--addons/website_sale_options/tests/__init__.py1
-rw-r--r--addons/website_sale_options/views/product_views.xml15
-rw-r--r--addons/website_sale_stock/__manifest__.py14
-rw-r--r--addons/website_sale_stock/controllers/main.py4
-rw-r--r--addons/website_sale_stock/static/description/icon.png (renamed from addons/website_sale_stock_options/static/description/icon.png)bin6644 -> 6644 bytes
-rw-r--r--addons/website_sale_stock/static/description/icon.svg (renamed from addons/website_sale_stock_options/static/description/icon.svg)0
-rw-r--r--addons/website_sale_stock/static/src/js/website_sale_stock.js21
-rw-r--r--addons/website_sale_stock/views/website_sale_stock_templates.xml10
-rw-r--r--addons/website_sale_stock_options/__init__.py2
-rw-r--r--addons/website_sale_stock_options/__manifest__.py22
-rw-r--r--addons/website_sale_stock_options/views/website_sale_stock_templates.xml13
83 files changed, 2891 insertions, 1144 deletions
diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py
index 6f604e13451..64dba64d84b 100644
--- a/addons/point_of_sale/tests/test_frontend.py
+++ b/addons/point_of_sale/tests/test_frontend.py
@@ -31,15 +31,14 @@ class TestUi(odoo.tests.HttpCase):
pear = env.ref('point_of_sale.whiteboard')
attribute_value = env['product.attribute.value'].create({
'name': 'add 2',
- 'product_ids': [(6, 0, [pear.id])],
'attribute_id': env['product.attribute'].create({
'name': 'add 2',
}).id,
})
- env['product.attribute.price'].create({
+ env['product.product.attribute.value'].create({
'product_tmpl_id': pear.product_tmpl_id.id,
'price_extra': 2,
- 'value_id': attribute_value.id,
+ 'product_attribute_value_id': attribute_value.id,
})
fixed_pricelist = env['product.pricelist'].create({
diff --git a/addons/product/data/product_demo.xml b/addons/product/data/product_demo.xml
index dd730d92904..0a2ac250e55 100644
--- a/addons/product/data/product_demo.xml
+++ b/addons/product/data/product_demo.xml
@@ -212,16 +212,6 @@
<field name="attribute_line_ids" eval="[(6,0,[ref('product.product_attribute_line_1'), ref('product.product_attribute_line_2')])]"/>
</record>
- <record id="product_product_4d" model="product.product">
- <field name="active" eval="False"/>
- </record>
-
- <record id="product_attribute_price_1" model="product.attribute.price">
- <field name="product_tmpl_id" ref="product_product_4_product_template"/>
- <field name="value_id" ref="product_attribute_value_2"/>
- <field name="price_extra">50.40</field>
- </record>
-
<record id="product_product_5" model="product.product">
<field name="name">Corner Desk Right Sit</field>
<field name="categ_id" ref="product_category_5"/>
@@ -320,14 +310,34 @@
<field name="attribute_id" ref="product_attribute_1"/>
<field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_1'), ref('product.product_attribute_value_2')])]"/>
</record>
-
+
<record id="product_product_11_product_template" model="product.template">
<field name="attribute_line_ids" eval="[(6,0,[ref('product.product_attribute_line_4')])]"/>
</record>
- <record id="product_attribute_price_2" model="product.attribute.price">
- <field name="product_tmpl_id" ref="product_product_11_product_template"/>
- <field name="value_id" ref="product_attribute_value_2"/>
+ <!--
+ Handle automatically created product.product.attribute.value.
+ Meaning that the combination between the "customizable desk" and the attribute value "black" will be materialized
+ into a "product.product.attribute.value" with the ref "product.product_product_attribute_value_1".
+ This will allow setting fields like "price_extra" and "exclude_for"
+ -->
+ <function model="ir.model.data" name="_update_xmlids">
+ <value model="base" eval="[{
+ 'xml_id': 'product.product_product_attribute_value_1',
+ 'record': obj().env.ref('product.product_attribute_line_1').product_value_ids[1],
+ 'noupdate': True,
+ }, {
+ 'xml_id': 'product.product_product_attribute_value_2',
+ 'record': obj().env.ref('product.product_attribute_line_4').product_value_ids[1],
+ 'noupdate': True,
+ }]"/>
+ </function>
+
+ <record id="product_product_attribute_value_1" model="product.product.attribute.value">
+ <field name="price_extra">50.40</field>
+ </record>
+
+ <record id="product_product_attribute_value_2" model="product.product.attribute.value">
<field name="price_extra">6.40</field>
</record>
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index 3318c9a075a..78cb392dcf2 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -93,7 +93,7 @@ class ProductProduct(models.Model):
lst_price = fields.Float(
'Sale Price', compute='_compute_product_lst_price',
digits=dp.get_precision('Product Price'), inverse='_set_product_lst_price',
- help="The sale price is managed from the product template. Click on the 'Variant Prices' button to set the extra attribute prices.")
+ help="The sale price is managed from the product template. Click on the 'Configure Variants' button to set the extra attribute prices.")
default_code = fields.Char('Internal Reference', index=True)
code = fields.Char('Reference', compute='_compute_product_code')
@@ -110,6 +110,8 @@ class ProductProduct(models.Model):
help="International Article Number used for product identification.")
attribute_value_ids = fields.Many2many(
'product.attribute.value', string='Attributes', ondelete='restrict')
+ product_attribute_value_ids = fields.Many2many(
+ 'product.product.attribute.value', string='Attribute Values', compute="_compute_product_attribute_value_ids")
# image: all image fields are base64 encoded and PIL-supported
image_variant = fields.Binary(
"Variant Image", attachment=True,
@@ -193,15 +195,10 @@ class ProductProduct(models.Model):
value -= product.price_extra
product.write({'list_price': value})
- @api.depends('attribute_value_ids.price_ids.price_extra', 'attribute_value_ids.price_ids.product_tmpl_id')
+ @api.depends('product_attribute_value_ids.price_extra')
def _compute_product_price_extra(self):
- # TDE FIXME: do a real multi and optimize a bit ?
for product in self:
- price_extra = 0.0
- for attribute_price in product.mapped('attribute_value_ids.price_ids'):
- if attribute_price.product_tmpl_id == product.product_tmpl_id:
- price_extra += attribute_price.price_extra
- product.price_extra = price_extra
+ product.price_extra = sum(product.mapped('product_attribute_value_ids.price_extra'))
@api.depends('list_price', 'price_extra')
def _compute_product_lst_price(self):
@@ -276,6 +273,12 @@ class ProductProduct(models.Model):
else:
self.product_tmpl_id.image = image
+ def _compute_product_attribute_value_ids(self):
+ for product in self:
+ product.product_attribute_value_ids = self.env['product.product.attribute.value']._search([
+ ('product_tmpl_id', '=', product.product_tmpl_id.id),
+ ('product_attribute_value_id', 'in', product.attribute_value_ids.ids)])
+
@api.one
def _get_pricelist_items(self):
self.pricelist_item_ids = self.env['product.pricelist.item'].search([
diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py
index e2c20145455..0a348370199 100644
--- a/addons/product/models/product_attribute.py
+++ b/addons/product/models/product_attribute.py
@@ -19,75 +19,67 @@ class ProductAttribute(models.Model):
create_variant = fields.Boolean(default=True, help="Check this if you want to create multiple variants for this attribute.")
-class ProductAttributevalue(models.Model):
+class ProductAttributeValue(models.Model):
_name = "product.attribute.value"
_order = 'attribute_id, sequence, id'
_description = 'Product Attribute Value'
- name = fields.Char('Attribute Value', required=True, translate=True)
- sequence = fields.Integer('Sequence', help="Determine the display order")
- attribute_id = fields.Many2one('product.attribute', 'Attribute', ondelete='cascade', required=True)
- product_ids = fields.Many2many('product.product', string='Variants', readonly=True)
- price_extra = fields.Float(
- 'Attribute Price Extra', compute='_compute_price_extra', inverse='_set_price_extra',
- default=0.0, digits=dp.get_precision('Product Price'),
- help="Price Extra: Extra price for the variant with this attribute value on sale price. eg. 200 price extra, 1000 + 200 = 1200.")
- price_ids = fields.One2many('product.attribute.price', 'value_id', 'Attribute Prices', readonly=True)
+ name = fields.Char(string='Value', required=True, translate=True)
+ sequence = fields.Integer(string='Sequence', help="Determine the display order")
+ attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='cascade', required=True)
_sql_constraints = [
- ('value_company_uniq', 'unique (name,attribute_id)', 'This attribute value already exists !')
+ ('value_company_uniq', 'unique (name, attribute_id)', 'This attribute value already exists !')
]
- @api.one
- def _compute_price_extra(self):
- if self._context.get('active_id'):
- price = self.price_ids.filtered(lambda price: price.product_tmpl_id.id == self._context['active_id'])
- self.price_extra = price.price_extra
- else:
- self.price_extra = 0.0
-
- def _set_price_extra(self):
- if not self._context.get('active_id'):
- return
-
- AttributePrice = self.env['product.attribute.price']
- prices = AttributePrice.search([('value_id', 'in', self.ids), ('product_tmpl_id', '=', self._context['active_id'])])
- updated = prices.mapped('value_id')
- if prices:
- prices.write({'price_extra': self.price_extra})
- else:
- for value in self - updated:
- AttributePrice.create({
- 'product_tmpl_id': self._context['active_id'],
- 'value_id': value.id,
- 'price_extra': self.price_extra,
- })
-
@api.multi
def name_get(self):
if not self._context.get('show_attribute', True): # TDE FIXME: not used
- return super(ProductAttributevalue, self).name_get()
+ return super(ProductAttributeValue, self).name_get()
return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self]
@api.multi
+ def _variant_name(self, variable_attributes):
+ return ", ".join([v.name for v in self if v.attribute_id in variable_attributes])
+
+ @api.multi
def unlink(self):
linked_products = self.env['product.product'].with_context(active_test=False).search([('attribute_value_ids', 'in', self.ids)])
if linked_products:
raise UserError(_('The operation cannot be completed:\nYou are trying to delete an attribute value with a reference on a product variant.'))
- return super(ProductAttributevalue, self).unlink()
+ return super(ProductAttributeValue, self).unlink()
+
+
+class ProductProductAttributeValue(models.Model):
+ _name = "product.product.attribute.value"
+
+ name = fields.Char('Value', related="product_attribute_value_id.name")
+ product_attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute Value', required=True, ondelete='cascade', index=True)
+ product_tmpl_id = fields.Many2one('product.template', string='Product Template', required=True, ondelete='cascade', index=True)
+ attribute_id = fields.Many2one('product.attribute', string='Attribute', related="product_attribute_value_id.attribute_id")
+ sequence = fields.Integer('product.sequence', related="product_attribute_value_id.sequence")
+ price_extra = fields.Float(
+ string='Attribute Price Extra', default=0.0, digits=dp.get_precision('Product Price'),
+ help="Price Extra: Extra price for the variant with this attribute value on sale price. eg. 200 price extra, 1000 + 200 = 1200.")
+ exclude_for = fields.Many2many(
+ 'product.attribute.filter.line', string="Exclude for", relation="product_attribute_value_exclusion",
+ help="""A list of product and attribute values that you want to exclude for this product's attribue value.
+ Also applies on optionnal and accessory products.""")
@api.multi
- def _variant_name(self, variable_attributes):
- return ", ".join([v.name for v in self if v.attribute_id in variable_attributes])
+ def name_get(self):
+ if not self._context.get('show_attribute', True): # TDE FIXME: not used
+ return super(ProductAttributeValue, self).name_get()
+ return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self]
-class ProductAttributePrice(models.Model):
- _name = "product.attribute.price"
- _description = 'Product Attribute Price'
+class ProductAttributeFilterLine(models.Model):
+ _name = "product.attribute.filter.line"
- product_tmpl_id = fields.Many2one('product.template', 'Product Template', ondelete='cascade', required=True)
- value_id = fields.Many2one('product.attribute.value', 'Product Attribute Value', ondelete='cascade', required=True)
- price_extra = fields.Float('Price Extra', digits=dp.get_precision('Product Price'))
+ product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True)
+ value_ids = fields.Many2many(
+ 'product.product.attribute.value', relation="product_attr_filter_line_value_ids_rel",
+ string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id), ('attribute_id.create_variant', '=', True)]")
class ProductAttributeLine(models.Model):
@@ -95,9 +87,10 @@ class ProductAttributeLine(models.Model):
_rec_name = 'attribute_id'
_description = 'Product Attribute Line'
- product_tmpl_id = fields.Many2one('product.template', 'Product Template', ondelete='cascade', required=True)
- attribute_id = fields.Many2one('product.attribute', 'Attribute', ondelete='restrict', required=True)
+ product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True)
+ attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True)
value_ids = fields.Many2many('product.attribute.value', string='Attribute Values')
+ product_value_ids = fields.Many2many('product.product.attribute.value', string='Product Attribute Values', compute="_set_product_value_ids")
@api.constrains('value_ids', 'attribute_id')
def _check_valid_attribute(self):
@@ -106,6 +99,64 @@ class ProductAttributeLine(models.Model):
return True
@api.model
+ def create(self, values):
+ res = super(ProductAttributeLine, self).create(values)
+ res._update_product_product_attribute_values()
+ return res
+
+ def write(self, values):
+ res = super(ProductAttributeLine, self).write(values)
+ self._update_product_product_attribute_values()
+ return res
+
+ @api.depends('value_ids')
+ def _set_product_value_ids(self):
+ for product_attribute_line in self:
+ product_attribute_line.product_value_ids = self.env['product.product.attribute.value'].search([
+ ('product_tmpl_id', 'in', product_attribute_line.product_tmpl_id.ids),
+ ('product_attribute_value_id.attribute_id', 'in', product_attribute_line.value_ids.mapped('attribute_id').ids)])
+
+ @api.multi
+ def unlink(self):
+ for product_attribute_line in self:
+ self.env['product.product.attribute.value'].search([
+ ('product_tmpl_id', 'in', product_attribute_line.product_tmpl_id.ids),
+ ('product_attribute_value_id.attribute_id', 'in', product_attribute_line.value_ids.mapped('attribute_id').ids)]).unlink()
+
+ return super(ProductAttributeLine, self).unlink()
+
+ def _update_product_product_attribute_values(self):
+ """
+ Create or unlink product.product.attribute.value based on the attribute lines.
+ If the product.attribute.value is removed, remove the corresponding product.product.attribute.value
+ If no product.product.attribute.value exists for the newly added product.attribute.value, create it.
+ """
+ for attribute_line in self:
+ # All existing product.product.attribute.value for this template
+ product_product_attribute_values_to_remove = self.env['product.product.attribute.value'].search([
+ ('product_tmpl_id', '=', attribute_line.product_tmpl_id.id),
+ ('product_attribute_value_id.attribute_id', 'in', attribute_line.value_ids.mapped('attribute_id').ids)])
+ # All existing product.attribute.value shared by all products
+ # eg (Yellow, Red, Blue, Small, Large)
+ existing_product_attribute_values = product_product_attribute_values_to_remove.mapped('product_attribute_value_id')
+
+ # Loop on product.attribute.values for the line (eg: Yellow, Red, Blue)
+ for product_attribute_value in attribute_line.value_ids:
+ if product_attribute_value in existing_product_attribute_values:
+ # property is already existing: don't touch, remove it from list to avoid unlinking it
+ product_product_attribute_values_to_remove = product_product_attribute_values_to_remove.filtered(
+ lambda value: product_attribute_value not in value.mapped('product_attribute_value_id')
+ )
+ else:
+ # property does not exist: create it
+ self.env['product.product.attribute.value'].create({
+ 'product_attribute_value_id': product_attribute_value.id,
+ 'product_tmpl_id': attribute_line.product_tmpl_id.id})
+ # at this point, existing properties can be removed to reflect the modifications on value_ids
+ if product_product_attribute_values_to_remove:
+ product_product_attribute_values_to_remove.unlink()
+
+ @api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
# TDE FIXME: currently overriding the domain; however as it includes a
# search on a m2o and one on a m2m, probably this will quickly become
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index e035e68cffe..2255403b4df 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -479,6 +479,59 @@ class ProductTemplate(models.Model):
return True
+ @api.multi
+ def get_filtered_variants(self, reference_product=None):
+ """
+ Will filter availability (excluded) for the product
+ combinations (ex: color: white excludes size: large).
+
+ Will also filter availability (excluded) for the parent
+ product if specified (meaning that this product is a an optionnal or
+ accessory product of the reference_product).
+ Args:
+ reference_product (product.product): The reference product that has
+ the current product as an option or accessory product.
+ Returns:
+ The filtered list of product variants
+ """
+ self.ensure_one()
+ product_product_attribute_values = self.env['product.product.attribute.value'].search([('product_tmpl_id', '=', self.id)])
+ if reference_product:
+ # append the reference_product if provided
+ product_product_attribute_values |= reference_product.product_attribute_value_ids
+ product_variants = self.product_variant_ids
+
+ for product_product_attribute_value in product_product_attribute_values:
+ # CASE 1: The whole product is excluded when no attribute values are selected in the parent product
+ # returns empty recordset of product.product if so. What is checked is:
+ # If the product_attribute value doesn't belong to self (i.e. belongs to the reference product)
+ # and self is the excluded product template on the exclusion lines
+ # and the exclusions is on the product without specified product attribute values (i.e. the whole product is excluded)
+ if product_product_attribute_value.product_tmpl_id != self \
+ and self in product_product_attribute_value.exclude_for.mapped('product_tmpl_id') \
+ and not product_product_attribute_value.exclude_for.filtered(
+ lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self).value_ids:
+ return self.env['product.product']
+
+ # CASE 2: Check if some of the product.product.attribute.value of the product are excluded
+ # for this prodcut. A variant could be excluded:
+ # - Either by itself (eg: The office chair with iron legs excludes the color white)
+ # - Or by the reference product (eg: The customizable desk with iron legs excludes the office chair with aluminium legs)
+ for excluded in product_product_attribute_value.exclude_for.filtered(
+ lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self):
+ product_variants -= product_variants.filtered(
+ lambda variant:
+ # 1/ Check the applicability of the exlusion
+ # i.e: the restiction comes from the parent
+ # OR the restriction is on a product_attribute_value that this variant has, eg:
+ # if the office chair with iron legs excludes the color white, we must check
+ # that this variant has iron legs to check the exclusion
+ (product_product_attribute_value.product_tmpl_id != self or product_product_attribute_value in variant.product_attribute_value_ids) and
+ # 2/ Check the variant has one of the exluded attribute values
+ any(attribute_value in excluded.value_ids for attribute_value in variant.product_attribute_value_ids))
+
+ return product_variants
+
@api.model
def get_empty_list_help(self, help):
self = self.with_context(
diff --git a/addons/product/models/res_config_settings.py b/addons/product/models/res_config_settings.py
index 67988dd6fe2..0750136085e 100644
--- a/addons/product/models/res_config_settings.py
+++ b/addons/product/models/res_config_settings.py
@@ -13,7 +13,7 @@ class ResConfigSettings(models.TransientModel):
" * Checked : Product are visible for every company, even if a company is defined on the partner.\n"
" * 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("Attributes and Variants", implied_group='product.group_product_variant')
+ group_product_variant = fields.Boolean("Variants and Options", implied_group='product.group_product_variant')
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",
diff --git a/addons/product/security/ir.model.access.csv b/addons/product/security/ir.model.access.csv
index 8518bb1fe64..4c76d40b2f6 100644
--- a/addons/product/security/ir.model.access.csv
+++ b/addons/product/security/ir.model.access.csv
@@ -10,7 +10,8 @@ access_product_product_employee,product.product employee,model_product_product,b
access_product_price_history_employee,prices.history employee,model_product_price_history,base.group_user,1,0,0,0
access_product_attribute,product.attribute,model_product_attribute,base.group_user,1,0,0,0
access_product_attribute_value,product.attribute value,model_product_attribute_value,base.group_user,1,0,0,0
-access_product_attribute_price,product.attribute price,model_product_attribute_price,base.group_user,1,0,0,0
+access_product_product_attribute,product.product.attribute value,model_product_product_attribute_value,base.group_user,1,0,0,0
+access_product_attribute_filter_line,product.attribute filter line,model_product_attribute_filter_line,base.group_user,1,0,0,0
access_product_attribute_line,product.attribute line,model_product_attribute_line,base.group_user,1,0,0,0
access_product_category_manager,product.category.manager,model_product_category,base.group_system,1,1,1,1
access_product_template_manager,product.template.manager,model_product_template,base.group_system,1,1,1,1
@@ -22,5 +23,6 @@ access_product_product_manager,product.product manager,model_product_product,bas
access_product_price_history_manager,prices.history manager,model_product_price_history,base.group_system,1,1,1,1
access_product_attribute_manager,product.attribute manager,model_product_attribute,base.group_system,1,1,1,1
access_product_attribute_value_manager,product.attribute value manager,model_product_attribute_value,base.group_system,1,1,1,1
-access_product_attribute_price_manager,product.attribute price manager,model_product_attribute_price,base.group_system,1,1,1,1
+access_product_product_attribute_manager,product.product.attribute value manager,model_product_product_attribute_value,base.group_system,1,1,1,1
+access_product_attribute_filter_line_manager,product.attribute filter line manager,model_product_attribute_filter_line,base.group_system,1,1,1,1
access_product_attribute_line_manager,product.attribute line manager,model_product_attribute_line,base.group_system,1,1,1,1
diff --git a/addons/product/tests/__init__.py b/addons/product/tests/__init__.py
index 96caad34928..2dfb724036d 100644
--- a/addons/product/tests/__init__.py
+++ b/addons/product/tests/__init__.py
@@ -1,4 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from . import test_variants, test_pricelist, test_product_pricelist, test_seller
+from . import test_variants
+from . import test_pricelist
+from . import test_product_pricelist
+from . import test_seller
+from . import test_product_attribute_value_config
diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py
new file mode 100644
index 00000000000..8a290aa9c02
--- /dev/null
+++ b/addons/product/tests/test_product_attribute_value_config.py
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests.common import TransactionCase
+
+
+class TestProductAttributeValueConfig(TransactionCase):
+ def setUp(self):
+ super(TestProductAttributeValueConfig, self).setUp()
+
+ self.computer = self.env['product.template'].create({
+ 'name': 'Super Computer'
+ })
+
+ self._add_ssd_attribute()
+ self._add_ram_attribute()
+ self._add_hdd_attribute()
+
+ self.computer.create_variant_ids()
+
+ self.computer_case = self.env['product.template'].create({
+ 'name': 'Super Computer Case'
+ })
+
+ self._add_size_attribute()
+
+ self.computer_case.create_variant_ids()
+
+ def test_product_product_attribute_values_creation(self):
+ self.assertEqual(len(self.computer_ssd_attribute_lines.product_value_ids), 2, 'Product attribute values (ssd) were not automatically created')
+ self.assertEqual(len(self.computer_ram_attribute_lines.product_value_ids), 3, 'Product attribute values (ram) were not automatically created')
+ self.assertEqual(len(self.computer_hdd_attribute_lines.product_value_ids), 3, 'Product attribute values (hdd) were not automatically created')
+ self.assertEqual(len(self.computer_case_size_attribute_lines.product_value_ids), 3, 'Product attribute values (size) were not automatically created')
+
+ def test_product_filtered_exclude_for(self):
+ """
+ Super Computer has 18 variants total (2 ssd * 3 ram * 3 hdd)
+ RAM 16 excudes HDD 1, that matches 2 variants:
+ - SSD 256 RAM 16 HDD 1
+ - SSD 512 RAM 16 HDD 1
+
+ => There has to be 16 variants left when filtered
+ """
+ self._add_ram_exclude_for()
+ self.assertEqual(len(self.computer.get_filtered_variants()), 16)
+ self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_256, self.ram_16, self.hdd_1]))
+ self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_512, self.ram_16, self.hdd_1]))
+
+ def test_children_product_filtered_exclude_for(self):
+ """
+ Super Computer Case has 3 variants total (3 size)
+ Reference product Computer with HDD 4 excludes Size M
+ The following variant will be excluded:
+ - Size M
+
+ => There has to be 2 variants left when filtered
+ """
+
+ self._add_hdd_excludes_computer_case()
+ hdd_4_variant = self.computer.product_variant_ids.filtered(
+ lambda variant: self.hdd_4 in variant.product_attribute_value_ids.mapped('product_attribute_value_id'))[0]
+ self.assertEqual(len(self.computer_case.get_filtered_variants(hdd_4_variant)), 2)
+ self.assertFalse(self._get_variant_for_attribute_values(self.computer_case, [self.size_m], hdd_4_variant))
+
+ def _add_ssd_attribute(self):
+ self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory'})
+ self.ssd_256 = self.env['product.attribute.value'].create({
+ 'name': '256 GB',
+ 'attribute_id': self.ssd_attribute.id
+ })
+ self.ssd_512 = self.env['product.attribute.value'].create({
+ 'name': '512 GB',
+ 'attribute_id': self.ssd_attribute.id
+ })
+ self.computer_ssd_attribute_lines = self.env['product.attribute.line'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_id': self.ssd_attribute.id,
+ 'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])]
+ })
+
+ def _add_ram_attribute(self):
+ self.ram_attribute = self.env['product.attribute'].create({'name': 'RAM'})
+ self.ram_8 = self.env['product.attribute.value'].create({
+ 'name': '8 GB',
+ 'attribute_id': self.ram_attribute.id
+ })
+ self.ram_16 = self.env['product.attribute.value'].create({
+ 'name': '16 GB',
+ 'attribute_id': self.ram_attribute.id
+ })
+ self.ram_32 = self.env['product.attribute.value'].create({
+ 'name': '32 GB',
+ 'attribute_id': self.ram_attribute.id
+ })
+ self.computer_ram_attribute_lines = self.env['product.attribute.line'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_id': self.ram_attribute.id,
+ 'value_ids': [(6, 0, [self.ram_8.id, self.ram_16.id, self.ram_32.id])]
+ })
+
+ def _add_hdd_attribute(self):
+ self.hdd_attribute = self.env['product.attribute'].create({'name': 'HDD'})
+ self.hdd_1 = self.env['product.attribute.value'].create({
+ 'name': '1 To',
+ 'attribute_id': self.hdd_attribute.id
+ })
+ self.hdd_2 = self.env['product.attribute.value'].create({
+ 'name': '2 To',
+ 'attribute_id': self.hdd_attribute.id
+ })
+ self.hdd_4 = self.env['product.attribute.value'].create({
+ 'name': '4 To',
+ 'attribute_id': self.hdd_attribute.id
+ })
+ self.computer_hdd_attribute_lines = self.env['product.attribute.line'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_id': self.hdd_attribute.id,
+ 'value_ids': [(6, 0, [self.hdd_1.id, self.hdd_2.id, self.hdd_4.id])]
+ })
+
+ def _add_ram_exclude_for(self):
+ self.ram_16_excludes_hdd_1 = self.env['product.attribute.filter.line'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'value_ids': [(6, 0, [self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_1).id])]
+ })
+ self._get_product_value_id(self.computer_ram_attribute_lines, self.ram_16).update({
+ 'exclude_for': [(6, 0, [self.ram_16_excludes_hdd_1.id])]
+ })
+
+ def _add_size_attribute(self):
+ self.size_attribute = self.env['product.attribute'].create({'name': 'Size'})
+ self.size_m = self.env['product.attribute.value'].create({
+ 'name': 'M',
+ 'attribute_id': self.size_attribute.id
+ })
+ self.size_l = self.env['product.attribute.value'].create({
+ 'name': 'L',
+ 'attribute_id': self.size_attribute.id
+ })
+ self.size_xl = self.env['product.attribute.value'].create({
+ 'name': 'XL',
+ 'attribute_id': self.size_attribute.id
+ })
+ self.computer_case_size_attribute_lines = self.env['product.attribute.line'].create({
+ 'product_tmpl_id': self.computer_case.id,
+ 'attribute_id': self.size_attribute.id,
+ 'value_ids': [(6, 0, [self.size_m.id, self.size_l.id, self.size_xl.id])]
+ })
+
+ def _add_hdd_excludes_computer_case(self):
+ self.hdd_4_excludes_computer_case_m = self.env['product.attribute.filter.line'].create({
+ 'product_tmpl_id': self.computer_case.id,
+ 'value_ids': [(6, 0, [self._get_product_value_id(self.computer_case_size_attribute_lines, self.size_m).id])]
+ })
+ self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_4).update({
+ 'exclude_for': [(6, 0, [self.hdd_4_excludes_computer_case_m.id])]
+ })
+
+ def _get_product_value_id(self, product_attribute_lines, product_attribute_value):
+ return product_attribute_lines.product_value_ids.filtered(
+ lambda product_value_id: product_value_id.product_attribute_value_id == product_attribute_value)[0]
+
+ def _get_variant_for_attribute_values(self, product, attribute_values, reference_product=None):
+ return product.get_filtered_variants(reference_product).filtered(
+ lambda variant:
+ all(attribute_value in variant.attribute_value_ids for attribute_value in attribute_values))
diff --git a/addons/product/views/product_attribute_views.xml b/addons/product/views/product_attribute_views.xml
index 0cc4c019f13..e3f9df657c4 100644
--- a/addons/product/views/product_attribute_views.xml
+++ b/addons/product/views/product_attribute_views.xml
@@ -1,95 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
- <record id="product_attribute_value_view_tree" model="ir.ui.view">
- <field name="name">product.attribute.value.view.tree</field>
- <field name="model">product.attribute.value</field>
+ <record id="product_product_attribute_value_view_tree" model="ir.ui.view">
+ <field name="name">product.product.attribute.value.view.tree</field>
+ <field name="model">product.product.attribute.value</field>
+ <field name="type">tree</field>
<field name="arch" type="xml">
- <tree string="Attributes" editable="top">
- <field name="sequence" widget="handle"/>
+ <tree string="Attributes" create="0" delete="0">
<field name="attribute_id"/>
<field name="name"/>
- <field name="price_extra"/>
+ <field name="price_extra" />
</tree>
</field>
</record>
+
+ <record id="product_product_attribute_value_view_form" model="ir.ui.view">
+ <field name="name">product.product.attribute.value.view.form.</field>
+ <field name="model">product.product.attribute.value</field>
+ <field name="type">form</field>
+ <field name="arch" type="xml">
+ <form string="Product Attribute" create="0" delete="0">
+ <group>
+ <field name="name" />
+ <field name="price_extra" />
+ <field name="exclude_for" widget="one2many_list" mode="tree">
+ <tree editable="top">
+ <field name="product_tmpl_id" />
+ <field name="value_ids" widget="many2many_tags" options="{'no_create': true}" />
+ </tree>
+ </field>
+ </group>
+ </form>
+ </field>
+ </record>
+
<record id="product_attribute_value_action" model="ir.actions.act_window">
- <field name="name">Variant Values</field>
- <field name="type">ir.actions.act_window</field>
- <field name="res_model">product.attribute.value</field>
- <field name="view_mode">tree</field>
- <field name="view_type">form</field>
- <field name="view_id" ref="product_attribute_value_view_tree"/>
- <field name="domain">[('product_ids.product_tmpl_id', '=', active_id)]</field>
- <field name="context">{'default_product_tmpl_id': active_id}</field>
+ <field name="name">Product Variant Values</field>
+ <field name="res_model">product.product.attribute.value</field>
+ <field name="view_mode">tree,form</field>
+ <field name="domain">[('product_tmpl_id', '=', active_id), ('attribute_id.create_variant', '=', True)]</field>
+ <field name="view_ids"
+ eval="[(5, 0, 0),
+ (0, 0, {'view_mode': 'tree', 'view_id': ref('product.product_product_attribute_value_view_tree')}),
+ (0, 0, {'view_mode': 'form', 'view_id': ref('product.product_product_attribute_value_view_form')})]" />
+ <field name="context">{
+ 'default_product_tmpl_id': active_id
+ }</field>
</record>
- <record id="attribute_tree_view" model="ir.ui.view">
- <field name="name">product.attribute.tree</field>
- <field name="model">product.attribute</field>
- <field name="arch" type="xml">
- <tree string="Variant Values" editable="top">
- <field name="sequence" widget="handle"/>
- <field name="name"/>
- <field name="create_variant"/>
- </tree>
- </field>
- </record>
+ <record id="attribute_tree_view" model="ir.ui.view">
+ <field name="name">product.attribute.tree</field>
+ <field name="model">product.attribute</field>
+ <field name="arch" type="xml">
+ <tree string="Variant Values" editable="top">
+ <field name="sequence" widget="handle"/>
+ <field name="name"/>
+ <field name="create_variant"/>
+ </tree>
+ </field>
+ </record>
- <record id="product_attribute_view_form" model="ir.ui.view">
- <field name="name">product.attribute.form</field>
- <field name="model">product.attribute</field>
- <field name="arch" type="xml">
- <form string="Product Attribute">
- <group>
- <group name="main_fields">
- <label for="name" string="Attribute Name"/>
- <field name="name" nolabel="1"/>
- </group>
- </group>
- <group name="values_ids">
- <label for="value_ids" string="Attribute Values"/>
- <field name="value_ids" widget="one2many_list" nolabel="1">
- <tree string="Values" editable="bottom">
- <field name="name"/>
- </tree>
- <form string="Values">
- <field name="name"/>
- </form>
- </field>
- <field name="create_variant"/>
+ <record id="product_attribute_view_form" model="ir.ui.view">
+ <field name="name">product.attribute.form</field>
+ <field name="model">product.attribute</field>
+ <field name="arch" type="xml">
+ <form string="Product Attribute">
+ <group>
+ <group name="main_fields">
+ <label for="name" string="Attribute Name"/>
+ <field name="name" nolabel="1"/>
</group>
- </form>
- </field>
- </record>
+ </group>
+ <group name="values_ids">
+ <label for="value_ids" string="Attribute Values"/>
+ <field name="value_ids" widget="one2many_list" nolabel="1">
+ <tree string="Values" editable="bottom">
+ <field name="name"/>
+ </tree>
+ <form string="Values">
+ <field name="name"/>
+ </form>
+ </field>
+ <field name="create_variant"/>
+ </group>
+ </form>
+ </field>
+ </record>
- <record id="attribute_action" model="ir.actions.act_window">
- <field name="name">Attributes</field>
- <field name="type">ir.actions.act_window</field>
- <field name="res_model">product.attribute</field>
- <field name="view_mode">tree</field>
- <field name="view_type">form</field>
- </record>
+ <record id="attribute_action" model="ir.actions.act_window">
+ <field name="name">Attributes</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">product.attribute</field>
+ <field name="view_mode">tree</field>
+ <field name="view_type">form</field>
+ </record>
- <record id="variants_tree_view" model="ir.ui.view">
- <field name="name">product.attribute.value.tree</field>
- <field name="model">product.attribute.value</field>
- <field name="arch" type="xml">
- <tree string="Variant Values" editable="top">
- <field name="sequence" widget="handle"/>
- <field name="attribute_id"/>
- <field name="name"/>
- </tree>
- </field>
- </record>
+ <record id="variants_tree_view" model="ir.ui.view">
+ <field name="name">product.attribute.value.tree</field>
+ <field name="model">product.attribute.value</field>
+ <field name="arch" type="xml">
+ <tree string="Variant Values" editable="top">
+ <field name="sequence" widget="handle"/>
+ <field name="attribute_id"/>
+ <field name="name"/>
+ </tree>
+ </field>
+ </record>
- <record id="variants_action" model="ir.actions.act_window">
- <field name="name">Attribute Values</field>
- <field name="type">ir.actions.act_window</field>
- <field name="res_model">product.attribute.value</field>
- <field name="view_mode">tree</field>
- <field name="view_type">form</field>
- </record>
+ <record id="variants_action" model="ir.actions.act_window">
+ <field name="name">Attribute Values</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">product.attribute.value</field>
+ <field name="view_mode">tree</field>
+ <field name="view_type">form</field>
+ </record>
<record id="product_attribute_line_form" model="ir.ui.view">
<field name="name">product.attribute.line.form</field>
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index 48e2714d99c..afeab2387f8 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -9,7 +9,7 @@
<field name="arch" type="xml">
<form string="Product">
<header>
- <button string="Variant Prices" type="action"
+ <button string="Configure Variants" type="action"
name="%(product_attribute_value_action)d"
attrs="{'invisible': [('product_variant_count', '&lt;=', 1)]}"
groups="product.group_product_variant"/>
diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py
index 03ea9b45f96..ec9faf38bd2 100644
--- a/addons/sale/__manifest__.py
+++ b/addons/sale/__manifest__.py
@@ -28,9 +28,12 @@ This module contains all the common features of Sales Management and eCommerce.
'views/mail_activity_views.xml',
'views/assets.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',
@@ -38,5 +41,5 @@ This module contains all the common features of Sales Management and eCommerce.
],
'uninstall_hook': "uninstall_hook",
'installable': True,
- 'auto_install': False,
+ 'auto_install': False
}
diff --git a/addons/sale/controllers/__init__.py b/addons/sale/controllers/__init__.py
index 1a195b71756..ac5f1269f82 100644
--- a/addons/sale/controllers/__init__.py
+++ b/addons/sale/controllers/__init__.py
@@ -3,3 +3,4 @@
from . import onboarding
from . import portal
+from . import product_configurator
diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py
new file mode 100644
index 00000000000..19145abbefa
--- /dev/null
+++ b/addons/sale/controllers/product_configurator.py
@@ -0,0 +1,138 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http, fields
+from odoo.http import request
+
+
+class ProductConfiguratorController(http.Controller):
+ @http.route(['/product_configurator/configure'], type='json', auth="user", methods=['POST'])
+ def configure(self, product_id, pricelist_id, **kw):
+ product_template = request.env['product.template'].browse(int(product_id))
+ to_currency = product_template.currency_id
+ pricelist = self._get_pricelist(pricelist_id)
+ if pricelist:
+ 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", {
+ 'product': product_template,
+ 'to_currency': to_currency,
+ 'pricelist': pricelist,
+ 'get_attribute_value_ids': self._get_attribute_value_ids
+ })
+
+ @http.route(['/product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
+ def show_optional_products(self, product_id, pricelist_id, **kw):
+ return self._show_optional_products(product_id, self._get_pricelist(pricelist_id), False, **kw)
+
+ @http.route(['/product_configurator/optional_product_items'], type='json', auth="user", methods=['POST'])
+ def optional_product_items(self, product_id, pricelist_id, **kw):
+ return self._optional_product_items(product_id, self._get_pricelist(pricelist_id), **kw)
+
+ @http.route(['/product_configurator/get_unit_price'], type='json', auth="user", methods=['POST'])
+ def get_unit_price(self, product_ids, add_qty, pricelist_id, **kw):
+ return self._get_unit_price(product_ids, add_qty, self._get_pricelist(pricelist_id))
+
+ def _optional_product_items(self, product_id, pricelist, **kw):
+ product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
+
+ return request.env['ir.ui.view'].render_template("sale.optional_product_items", {
+ 'product': product,
+ 'reference_product': product,
+ 'pricelist': pricelist,
+ 'get_attribute_value_ids': self._get_attribute_value_ids,
+ 'main_product_attr_ids': self._get_main_product_attr_ids(product),
+ })
+
+ def _show_optional_products(self, product_id, pricelist, handle_stock, **kw):
+ # quantity = kw['kwargs']['context']['quantity']
+
+ product = request.env['product.product'].browse(int(product_id))
+ from_currency = request.env.user.company_id.currency_id
+ to_currency = pricelist.currency_id if pricelist else product.currency_id
+ company = request.env['res.company'].browse(request.env.context.get('company_id')) or request.env['res.users']._get_company()
+ date = request.env.context.get('date') or fields.Date.today()
+ compute_currency = lambda price: from_currency._convert(price, to_currency, company, date)
+
+ product = product.with_context(self._get_product_context(pricelist, **kw))
+
+ has_optional_products = False
+ for optional_product in product.optional_product_ids:
+ if optional_product.get_filtered_variants(product):
+ has_optional_products = True
+ break
+
+ if not has_optional_products:
+ return False
+ else:
+ return request.env['ir.ui.view'].render_template("sale.optional_products_modal", {
+ 'product': product,
+ 'reference_product': product,
+ 'pricelist': pricelist,
+ 'compute_currency': compute_currency,
+ 'to_currency': to_currency,
+ 'handle_stock': handle_stock,
+ 'get_attribute_value_ids': self._get_attribute_value_ids,
+ 'main_product_attr_ids': self._get_main_product_attr_ids(product),
+ })
+
+ def _get_attribute_value_ids(self, product, reference_product=None, pricelist=None):
+ """ list of selectable attributes of a product
+
+ Args:
+ - product (product.template): The base product template that will be split into variants
+ - reference_product (product.product): The reference product from which 'product' is an optional or accessory product
+ - pricelist (product.pricelist): A pricelist that will be used to compute the product price
+
+ :return: list of product variant description
+ (variant id, [visible attribute ids], variant price, variant sale price)
+ """
+ # product attributes with at least two choices
+ quantity = product._context.get('quantity') or 1
+ product = product.with_context(quantity=quantity, partner=request.env.user.partner_id)
+ to_currency = None
+ if pricelist:
+ product = product.with_context(pricelist=pricelist.id)
+ to_currency = pricelist.currency_id
+
+ visible_attrs_ids = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id').ids
+ attribute_value_ids = []
+ for variant in product.get_filtered_variants(reference_product):
+ public_price = variant.lst_price
+ if to_currency and to_currency != product.currency_id:
+ public_price = variant.currency_id._convert(
+ public_price, to_currency,
+ request.env.user.company_id, fields.Date.today()
+ )
+
+ visible_attribute_ids = [v.id for v in variant.product_attribute_value_ids if v.attribute_id.id in visible_attrs_ids]
+ attribute_value_ids.append([variant.id, visible_attribute_ids, variant.price if variant.price else variant.lst_price, public_price, variant.display_name, len(variant.optional_product_ids) > 0])
+
+ return attribute_value_ids
+
+ def _get_main_product_attr_ids(self, product):
+ main_product_attr_ids = self._get_attribute_value_ids(product.product_tmpl_id)
+ for variant in main_product_attr_ids:
+ if variant[0] == product.id:
+ # We indeed need a list of lists (even with only 1 element)
+ main_product_attr_ids = [variant]
+ break
+
+ return main_product_attr_ids
+
+ def _get_product_context(self, pricelist=None, **kw):
+ product_context = dict(request.context)
+ if pricelist:
+ if not product_context.get('pricelist'):
+ product_context['pricelist'] = pricelist.id
+ product_context.update(kw.get('kwargs', {}).get('context', {}))
+
+ return product_context
+
+ def _get_unit_price(self, product_ids, add_qty, pricelist, **kw):
+ products = request.env['product.product'].with_context({'quantity': add_qty, 'pricelist': pricelist.id if pricelist else None}).browse(product_ids)
+ return {product.id: product.price if product.price else product.lst_price for product in products}
+
+ def _get_pricelist(self, pricelist_id):
+ return request.env['product.pricelist'].browse(int(pricelist_id)) if pricelist_id and pricelist_id != '0' else None
diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml
index 7f8f5a1693b..496a58721af 100644
--- a/addons/sale/data/sale_demo.xml
+++ b/addons/sale/data/sale_demo.xml
@@ -691,6 +691,19 @@ Thanks!</field>
<field name="price_unit">38.25</field>
</record>
+ <record id="product.product_attribute_2" model="product.attribute">
+ <field name="type">color</field>
+ </record>
+ <record id="product.product_attribute_3" model="product.attribute">
+ <field name="type">select</field>
+ </record>
+
+ <record id="product.product_attribute_value_3" model="product.attribute.value">
+ <field name="html_color">#FFFFFF</field>
+ </record>
+ <record id="product.product_attribute_value_4" model="product.attribute.value">
+ <field name="html_color">#000000</field>
+ </record>
<!-- Confirm some Sales Orders-->
<function model="sale.order" name="action_confirm" eval="[[ref('portal_sale_order_2')]]"/>
@@ -700,5 +713,72 @@ Thanks!</field>
<function model="crm.team" name="message_subscribe"
eval="[ref('sales_team.team_sales_department'),ref('sales_team.salesteam_website_sales')], [ref('base.partner_root')]"/>
+ <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="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>
+
+ <!--
+ Handle automatically created product.product.attribute.value.
+ Check "product.product_product_attribute_value_1" for more information about this
+ -->
+ <function model="ir.model.data" name="_update_xmlids">
+ <value model="base" eval="[{
+ 'xml_id': 'sale.product_product_attribute_value_3',
+ 'record': obj().env.ref('product.product_attribute_line_1').product_value_ids[0],
+ 'noupdate': True,
+ }, {
+ 'xml_id': 'sale.product_product_attribute_value_4',
+ 'record': obj().env.ref('product.product_attribute_line_4').product_value_ids[0],
+ 'noupdate': True,
+ }, {
+ 'xml_id': 'sale.product_product_attribute_value_5',
+ 'record': obj().env.ref('product.product_attribute_line_2').product_value_ids[1],
+ 'noupdate': True,
+ }]"/>
+ </function>
+
+ <record id="product_attribute_filter_line_1" model="product.attribute.filter.line">
+ <field name="product_tmpl_id" ref="product.product_product_4_product_template" />
+ <field name="value_ids" eval="[(6,0,[ref('sale.product_product_attribute_value_5')])]"/>
+ </record>
+
+ <record id="product_attribute_filter_line_2" model="product.attribute.filter.line">
+ <field name="product_tmpl_id" ref="product.product_product_11_product_template" />
+ <field name="value_ids" eval="[(6,0,[ref('sale.product_product_attribute_value_4')])]"/>
+ </record>
+
+ <!--
+ The "Customizable Desk's Aluminium" attribute value will excude:
+ - The "Customizable Desk's Black" attribute
+ - The "Office Chair's Steel" attribute
+ -->
+ <record id="product.product_product_attribute_value_1" model="product.product.attribute.value">
+ <field name="exclude_for" eval="[(6,0,[ref('sale.product_attribute_filter_line_1') ,ref('sale.product_attribute_filter_line_2')])]" />
+ </record>
+
+ <record id="product_attribute_filter_line_3" model="product.attribute.filter.line">
+ <field name="product_tmpl_id" ref="product.product_product_11_product_template" />
+ <field name="value_ids" eval="[(6,0,[ref('product.product_product_attribute_value_2')])]"/>
+ </record>
+
+ <!--
+ The "Customizable Desk's Steel" attribute value will excude:
+ - The "Office Chair's Aluminium" attribute
+ -->
+ <record id="product_product_attribute_value_3" model="product.product.attribute.value">
+ <field name="exclude_for" eval="[(6,0,[ref('sale.product_attribute_filter_line_3')])]" />
+ </record>
</data>
</odoo>
diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py
index 4061ecea987..02096def606 100644
--- a/addons/sale/models/product_product.py
+++ b/addons/sale/models/product_product.py
@@ -6,6 +6,16 @@ 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'
@@ -42,3 +52,29 @@ class ProductProduct(models.Model):
def _get_invoice_policy(self):
return self.invoice_policy
+
+
+class ProductAttribute(models.Model):
+ _inherit = "product.attribute"
+
+ # YTI FIX ME: PLEASE RENAME ME INTO attribute_type
+ type = fields.Selection([
+ ('radio', 'Radio'),
+ ('select', 'Select'),
+ ('color', 'Color')], default='radio')
+
+
+class ProductAttributeValue(models.Model):
+ _inherit = "product.attribute.value"
+
+ html_color = fields.Char(
+ string='HTML Color Index', oldname='color',
+ help="""Here you can set a
+ specific HTML color index (e.g. #ff0000) to display the color if the
+ attibute type is 'Color'.""")
+
+
+class ProductProductAttributeValue(models.Model):
+ _inherit = "product.product.attribute.value"
+
+ html_color = fields.Char('HTML Color Index', related="product_attribute_value_id.html_color") \ No newline at end of file
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index aa317ebff59..df3cedc0cde 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -159,6 +159,7 @@ class SaleOrder(models.Model):
partner_shipping_id = fields.Many2one('res.partner', string='Delivery Address', readonly=True, required=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]}, help="Delivery address for current sales order.")
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order.")
+ pricelist_id_id = fields.Integer(string="Pricelist ID", related='pricelist_id.id', readonly=True)
currency_id = fields.Many2one("res.currency", related='pricelist_id.currency_id', string="Currency", readonly=True, required=True)
analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False, oldname='project_id')
@@ -928,6 +929,16 @@ class SaleOrderLine(models.Model):
else:
line.invoice_status = 'no'
+ @api.model
+ def default_get(self, fields):
+ res = super(SaleOrderLine, self).default_get(fields)
+ if self._context.get('product_id'):
+ res['product_id'] = int(self._context.get('product_id'))
+ if self._context.get('quantity'):
+ res['product_uom_qty'] = float(self._context.get('quantity'))
+
+ return res
+
@api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id')
def _compute_amount(self):
"""
@@ -1384,7 +1395,7 @@ class SaleOrderLine(models.Model):
domain = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id):
vals['product_uom'] = self.product_id.uom_id
- vals['product_uom_qty'] = 1.0
+ vals['product_uom_qty'] = self.product_uom_qty or 1.0
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
diff --git a/addons/sale/security/ir.model.access.csv b/addons/sale/security/ir.model.access.csv
index eeb1e148252..71eb9fa6e41 100644
--- a/addons/sale/security/ir.model.access.csv
+++ b/addons/sale/security/ir.model.access.csv
@@ -41,7 +41,8 @@ access_product_template_sale_manager,product.template salemanager,model_product_
access_product_product_sale_manager,product.product salemanager,model_product_product,sales_team.group_sale_manager,1,1,1,1
access_product_attribute_sale_manager,product.attribute manager,product.model_product_attribute,sales_team.group_sale_manager,1,1,1,1
access_product_attribute_value_sale_manager,product.attribute manager value,product.model_product_attribute_value,sales_team.group_sale_manager,1,1,1,1
-access_product_attribute_price_sale_manager,product.attribute manager price,product.model_product_attribute_price,sales_team.group_sale_manager,1,1,1,1
+access_product_product_attribute_sale_manager,product.product.attribute manager value,product.model_product_product_attribute_value,sales_team.group_sale_manager,1,1,1,1
+access_product_attribute_filter_line_sale_manager,product.attribute manager filter line,product.model_product_attribute_filter_line,sales_team.group_sale_manager,1,1,1,1
access_product_attribute_line_sale_manager,product.attribute manager line,product.model_product_attribute_line,sales_team.group_sale_manager,1,1,1,1
access_account_tax_sale_manager,account.tax sale manager,account.model_account_tax,sales_team.group_sale_salesman,1,0,0,0
access_account_journal_sale_manager,account.journal sale manager,account.model_account_journal,sales_team.group_sale_salesman,1,0,0,0
diff --git a/addons/sale/static/img/floor_protection-image.png b/addons/sale/static/img/floor_protection-image.png
new file mode 100644
index 00000000000..ef540b90c91
--- /dev/null
+++ b/addons/sale/static/img/floor_protection-image.png
Binary files differ
diff --git a/addons/sale/static/src/js/product_configurator.js b/addons/sale/static/src/js/product_configurator.js
new file mode 100644
index 00000000000..0be903d31d3
--- /dev/null
+++ b/addons/sale/static/src/js/product_configurator.js
@@ -0,0 +1,168 @@
+odoo.define('sale.ProductConfiguratorFormRenderer', function (require) {
+ "use strict";
+
+ var FormRenderer = require('web.FormRenderer');
+ var ProductConfiguratorUtils = require('sale.product_configurator_utils');
+
+ var ProductConfiguratorFormRenderer = FormRenderer.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this._super.apply(this, arguments);
+ this.$el.append('<div class="configurator_container"></div>');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @public
+ * Renders the product configurator within the form
+ */
+ render_configurator: function (configurator) {
+ var $configurator_container = this.$el.find('.configurator_container');
+ $configurator_container.empty();
+
+ var $configurator = $(configurator);
+ $configurator.appendTo($configurator_container);
+
+ ProductConfiguratorUtils.addConfiguratorEvents($configurator_container, "", false, $('.js_sale_order_pricelist_id').html());
+
+ $('.js_add_cart_variants', $configurator_container).each(function () {
+ $('input.js_variant_change, select.js_variant_change', this).first().trigger('change');
+ });
+ }
+ });
+
+ return ProductConfiguratorFormRenderer;
+});
+
+odoo.define('sale.ProductConfiguratorFormController', function (require) {
+ "use strict";
+
+ var core = require('web.core');
+ var _t = core._t;
+ var ajax = require('web.ajax');
+ var FormController = require('web.FormController');
+ var OptionalProductsModal = require('sale.OptionalProductsModal');
+
+ var ProductConfiguratorFormController = FormController.extend({
+ custom_events: _.extend({}, FormController.prototype.custom_events, {
+ field_changed: '_onFieldChanged'
+ }),
+ className: 'o_product_configurator',
+ /**
+ * @override
+ * We need to override the default click behavior for our "Add" button
+ * because there is a possibility that this product has optional products.
+ * If so, we need to display an extra modal to choose the options
+ */
+ _onButtonClicked: function (event) {
+ if(event.stopPropagation){
+ event.stopPropagation();
+ }
+ var attrs = event.data.attrs;
+ if (attrs.special === 'cancel') {
+ this._super.apply(this, arguments);
+ } else {
+ if(!this.$el.parents('.modal').find('.o_sale_product_configurator_add').hasClass('disabled')){
+ this._handleAdd(this.$el);
+ }
+ }
+ },
+ /**
+ * @override
+ * This is overriden to allow catching the "select" event on our product template select field.
+ * This will not work anymore if more fields are added to the form.
+ * TODO: Find a better way to catch that event.
+ */
+ _onFieldChanged: function (event) {
+ var self = this;
+
+ self.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled');
+
+ ajax.jsonRpc("/product_configurator/configure", 'call', {
+ 'product_id': event.data.changes.product_template_id.id,
+ 'pricelist_id': $('.js_sale_order_pricelist_id').html()
+ }).then(function (configurator) {
+ self.renderer.render_configurator(configurator);
+ });
+
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * When the user adds a product that has optional products, we need to display
+ * a window to allow the user to choose these extra options
+ */
+ _handleAdd: function ($modal) {
+ var self = this;
+ var quantity = parseFloat($modal.find('input[name="add_qty"]').val() || 1);
+ var product_id = parseInt($modal.find('input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked').first().val(), 10);
+
+ var modal = new OptionalProductsModal(quantity, product_id, $('body'), $('.js_sale_order_pricelist_id').html());
+
+ // No optional products found for this product,
+ // only add the root product
+ modal.on('options_empty', null, function () {
+ self._addProducts([{
+ product_id: product_id,
+ quantity: quantity
+ }]);
+ });
+
+ // Add all the selected products
+ modal.on('confirm', null, function () {
+ self._addProducts(modal.getSelectedProducts());
+ });
+
+ // Change the buttons translation when the modal is ready
+ modal.on('modal_ready', null, function ($modal) {
+ $modal.find('a.go-back').attr('title', _t('Back'));
+ $modal.find('a.go-back .d-md-inline').html(_t('Back'));
+ $modal.find('a.confirm').attr('title', _t('Confirm'));
+ $modal.find('a.confirm .d-md-inline').html(_t('Confirm'));
+ });
+
+ modal.appendTo($('body'));
+ },
+
+ /**
+ * @private
+ * This triggers the close action for the window and adds the product as the "infos" parameter.
+ * It will allow the caller of this window to handle the added products.
+ */
+ _addProducts: function(products) {
+ this.do_action({type: 'ir.actions.act_window_close', infos: products});
+ }
+ });
+
+ return ProductConfiguratorFormController;
+});
+
+odoo.define('sale.ProductConfiguratorFormView', function (require) {
+ "use strict";
+
+ var ProductConfiguratorFormController = require('sale.ProductConfiguratorFormController');
+ var ProductConfiguratorFormRenderer = require('sale.ProductConfiguratorFormRenderer');
+ var FormView = require('web.FormView');
+ var viewRegistry = require('web.view_registry');
+
+ var ProductConfiguratorFormView = FormView.extend({
+ config: _.extend({}, FormView.prototype.config, {
+ Controller: ProductConfiguratorFormController,
+ Renderer: ProductConfiguratorFormRenderer,
+ }),
+ });
+
+ viewRegistry.add('product_configurator_form', ProductConfiguratorFormView);
+
+ return ProductConfiguratorFormView;
+}); \ No newline at end of file
diff --git a/addons/sale/static/src/js/product_configurator_list_editable_renderer.js b/addons/sale/static/src/js/product_configurator_list_editable_renderer.js
new file mode 100644
index 00000000000..f69fb47a4af
--- /dev/null
+++ b/addons/sale/static/src/js/product_configurator_list_editable_renderer.js
@@ -0,0 +1,71 @@
+/**
+ * This adds the possibility to open the product configurator when the control
+ * "Configure a product" is clicked
+ */
+odoo.define('sale.ProductConfiguratorEditableListRenderer', function (require) {
+ "use strict";
+
+ var core = require('web.core');
+ var _t = core._t;
+ var ListRenderer = require('web.ListRenderer');
+
+ ListRenderer.include({
+ /**
+ * @override
+ */
+ _onAddRecord: function (ev) {
+ // we don't want the browser to navigate to a the # url
+ ev.preventDefault();
+
+ // we don't want the click to cause other effects, such as unselecting
+ // the row that we are creating, because it counts as a click on a tr
+ ev.stopPropagation();
+
+ // but we do want to unselect current row
+ var self = this;
+ this.unselectRow().then(function () {
+ var open_product_configurator = false;
+ var context = ev.currentTarget.dataset.context;
+
+ var context_json = false;
+ if(context){
+ context = context.replace(/\'/g, '"');
+ context_json = JSON.parse(context);
+
+ open_product_configurator = context_json.open_product_configurator;
+ }
+
+ if(open_product_configurator){
+ self._rpc({
+ model: 'ir.model.data',
+ method: 'xmlid_to_res_id',
+ kwargs: {xmlid: 'sale.sale_product_configurator_view_form'},
+ }).then(function(res_id) {
+ self.do_action({
+ name: _t('Configure a product'),
+ type: 'ir.actions.act_window',
+ res_model: 'sale.product.configurator',
+ views: [[res_id, 'form']],
+ target: 'new'
+ }, {
+ on_close: function (products) {
+ if(products && products != 'special'){
+ self.trigger_up('add_record', {
+ context: products,
+ forceEditable: "bottom" ,
+ allowWarning: true,
+ onSuccess: function(){
+ self.unselectRow();
+ }
+ });
+ }
+ }
+ });
+ });
+ } else {
+ self.trigger_up('add_record', {context: context && [context]}); // TODO write a test, the deferred was not considered
+ }
+ });
+ }
+ });
+}); \ No newline at end of file
diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js
new file mode 100644
index 00000000000..0c0c1c422ba
--- /dev/null
+++ b/addons/sale/static/src/js/product_configurator_modal.js
@@ -0,0 +1,298 @@
+odoo.define('sale.OptionalProductsModal', function (require) {
+ "use strict";
+
+var ajax = require('web.ajax');
+var Widget = require('web.Widget');
+var ServicesMixin = require('web.ServicesMixin');
+var weContext = require('web_editor.context');
+var ProductConfiguratorUtils = require('sale.product_configurator_utils');
+
+var product_name_map = {};
+var optional_products_map = {};
+
+var OptionalProductsModal = Widget.extend(ServicesMixin, {
+ events: _.extend({}, Widget.prototype.events, {
+ 'click .css_attribute_color input': '_onColorClick',
+ }),
+ /**
+ * @override
+ */
+ init: function (quantity, product_id, container, pricelistId, isWebsite) {
+ this._super.apply(this, arguments);
+ this.quantity = quantity;
+ this.product_id = product_id;
+ this.container = container;
+ this.pricelistId = pricelistId;
+ this.isWebsite = isWebsite;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+
+ var getModalContent = ajax.jsonRpc(this._getUri("/product_configurator/show_optional_products"), 'call', {
+ product_id: self.product_id,
+ pricelist_id: self.pricelistId,
+ kwargs: {
+ context: _.extend({'quantity': self.quantity}, weContext.get()),
+ }
+ })
+ .then(function (modal_content) {
+ self.modal_content = modal_content;
+ });
+
+ var parentInit = self._super.apply(self, arguments);
+ return $.when(getModalContent, parentInit);
+ },
+ /**
+ * @override
+ */
+ start: function() {
+ this._renderModal();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @public
+ * Returns the list of selected products as products[{product_id, quantity}]
+ */
+ getSelectedProducts: function() {
+ var self = this;
+ var products = [];
+ this.$modal.find('.js_product.in_cart').each(function(){
+ var quantity = 0;
+ var $item = $(this);
+ if($item.find('input[name="add_qty"]').length){
+ quantity = $item.find('input[name="add_qty"]').val();
+ } else {
+ quantity = parseInt($item.find('.optional_product_quantity span.add_qty').html().trim());
+ }
+
+ products.push({
+ product_id: $item.find('input.product_id').length ? $item.find('input.product_id').val() : self.product_id,
+ quantity: quantity
+ });
+ });
+
+ return products;
+ },
+
+ // ------------------------------------------
+ // Private
+ // ------------------------------------------
+
+ _renderModal: function(){
+ var self = this;
+ if (self.modal_content){
+ var $modal = $(self.modal_content);
+ $modal.find('img:first').attr("src", "/web/image/product.product/" + self.product_id + "/image_medium");
+
+ $modal
+ .modal()
+ .appendTo(self.container)
+ .addClass('oe_optional_products_modal' + (self.isWebsite ? ' oe_website_sale' : ''))
+ .on('hidden.bs.modal', function () {
+ $(this).remove();
+ })
+ .on('shown.bs.modal', function () {
+ self.trigger('modal_ready', $modal);
+ });
+
+ self.$modal = $modal;
+ self._addModalEvents();
+ } else {
+ self.trigger('options_empty');
+ }
+ },
+
+ /**
+ * Website behavior is slighlty different from backend so we append "_website" to URLs to lead to a different route
+ */
+ _getUri: function(uri) {
+ if (this.isWebsite){
+ return uri + '_website';
+ } else {
+ return uri;
+ }
+ },
+
+ _addModalEvents: function() {
+ this.$modal.on('click', '.a-submit', {self: this}, this._onSubmit);
+ this.$modal.on('click', '.css_attribute_color input', this._onColorClick);
+ this.$modal.on("click", 'a.js_add, a.js_remove', {self: this}, this._onAddOrRemoveOption);
+ this.$modal.on("change", "input.js_quantity", {self: this}, this._onChangeQuantity);
+ this.$modal.on("change", 'input[name="add_qty"]', this._onChangeMainProductQuantity);
+ ProductConfiguratorUtils.addConfiguratorEvents(this.$modal, "small", this.isWebsite, this.pricelistId);
+
+ // trigger add_qty change to synchronise quantity with previous window
+ this.$modal.find('input[name="add_qty"]').val(this.quantity).change();
+
+ // trigger input change to validate fields and display custom prices
+ $('.js_add_cart_variants', this.$modal).each(function () {
+ $('input.js_variant_change, select.js_variant_change', this).first().trigger('change');
+ });
+ },
+
+ /**
+ * Handles the "Back" and "Confirm" buttons and triggers the according events
+ */
+ _onSubmit: function(ev){
+ var $modal = $(this).parents('.oe_optional_products_modal');
+ $modal.modal('hide');
+ ev.preventDefault();
+
+ var $a = $(this);
+ ev.data.self.trigger($a.hasClass('confirm') ? 'confirm' : 'back');
+ },
+
+ /**
+ * TODO (awa): check if necessary
+ */
+ _onColorClick: function(){
+ var $modal = $(this).parents('.oe_optional_products_modal');
+ $modal.find('.css_attribute_color').removeClass("active");
+ $modal.find('.css_attribute_color:has(input:checked)').addClass("active");
+ },
+
+ _onAddOrRemoveOption: function(ev){
+ ev.preventDefault();
+ var $modal = $(this).parents('.oe_optional_products_modal');
+ var $select_options_text = $modal.find('.o_select_options');
+ var $main_product = $modal.find('.js_product:first');
+ var $parent = $(this).parents('.js_product:first');
+ $parent.find("a.js_add, span.js_remove").toggleClass('d-none');
+ $parent.find("input.js_optional_same_quantity").val( $(this).hasClass("js_add") ? 1 : 0 );
+ $parent.find(".js_remove");
+
+ var product_id = $parent.find(".product_template_id").val();
+ if ($(this).hasClass('js_add')) {
+ // remove attribute values selection and move quantity to the correct table spot (".td-qty"))
+ $parent.addClass('in_cart');
+ $parent.find('.td-qty').addClass('text-center');
+ $parent.find('.td-qty').append($parent.find('.optional_product_quantity'));
+
+ // change product name to display_name (containing attribute values) when it's added to cart
+ // the "simple" name is kept into a map to revert to it if the product is removed from the cart
+ product_name_map[product_id] = $parent.find(".product-name").html();
+ $parent.find(".product-name").html($parent.find(".product_display_name").val());
+
+ // if it's an optional product of an optional product, place it after it's parent
+ var parent_product_id = null;
+ for (var produt_id_key in optional_products_map) {
+ if (optional_products_map[produt_id_key].indexOf(product_id) != -1) {
+ parent_product_id = produt_id_key;
+ break;
+ }
+
+ $('tr:last').after($parent);
+ }
+
+ if (parent_product_id) {
+ $modal.find('.product_template_id').filter(function () {
+ return parent_product_id === $(this).val();
+ }).parents('.js_product:first').after($parent);
+ } else {
+ // else, place it after the main product
+ $main_product.after($parent);
+ }
+
+ var isWebsite = ev.data.self.isWebsite;
+ var pricelistId = ev.data.self.pricelistId;
+ var product_variant_id = $parent.find('.product_id').val();
+ ajax.jsonRpc(ev.data.self._getUri("/product_configurator/optional_product_items"), 'call', {
+ 'product_id': product_variant_id,
+ 'pricelist_id': ev.data.self.pricelistId
+ }).then(function (addedItem) {
+ var $addedItem = $(addedItem);
+ $modal.find('tr:last').after($addedItem);
+
+ ProductConfiguratorUtils.addConfiguratorEvents($addedItem, "small", isWebsite, pricelistId);
+
+ // trigger input change to validate fields
+ $('.js_add_cart_variants', $addedItem).each(function () {
+ $('input.js_variant_change, select.js_variant_change', this).first().trigger('change');
+ });
+
+ // update new options quantity
+ $modal.find('input[name="add_qty"]').trigger('change');
+
+ if ($addedItem.find(".product_template_id").length > 0) {
+ // we need to map this product with it's optional products to be able to:
+ // - remove them from the cart if the parent product is removed
+ // - place the optional products under their parent in the interface when they're added to the cart
+ optional_products_map[product_id] = $addedItem.find(".product_template_id").map(function () {
+ return $(this).val();
+ }).get();
+ }
+
+ if($select_options_text.nextAll('.js_product').length === 0){
+ // no more optional products to select -> hide the header
+ $select_options_text.hide();
+ }
+ });
+ } else {
+ // restore attribute values selection
+ $parent.removeClass('in_cart');
+ $parent.find('.td-qty').removeClass('text-center');
+ $parent.find('.js_remove.d-none').prepend($parent.find('.optional_product_quantity'));
+
+ $select_options_text.show();
+
+ // revert back to original product name and clean the entry
+ $parent.find(".product-name").html(product_name_map[product_id]);
+ delete product_name_map[product_id];
+
+ if (optional_products_map[product_id]) {
+ // if the removed product had optional products, remove them as well
+ optional_products_map[product_id].forEach(function (productId) {
+ $modal.find('.product_template_id').filter(function () {
+ return productId === $(this).val();
+ }).parents('.js_product:first').remove();
+ });
+ delete optional_products_map[product_id];
+ }
+
+ $('tr:last').after($parent);
+ }
+
+ ProductConfiguratorUtils.computePriceTotal();
+ },
+
+ _onChangeQuantity: function(ev){
+ var qty = parseFloat($(this).val());
+ var $modal = $(this).parents('.oe_optional_products_modal');
+ if (qty === 1) {
+ $modal.find(".js_items").addClass('d-none').removeClass('add_qty');
+ $modal.find(".js_item").removeClass('d-none').addClass('add_qty');
+ } else {
+ $modal.find(".js_items").removeClass('d-none').addClass('add_qty').text(qty);
+ $modal.find(".js_item").addClass('d-none').removeClass('add_qty');
+ }
+ },
+
+ _onChangeMainProductQuantity: function(){
+ var $modal = $(this).parents('.oe_optional_products_modal');
+ var product_id = $modal.find('span.oe_price[data-product-id]').first().data('product-id');
+ var product_ids = [product_id];
+ var $products_dom = [];
+ $modal.find("ul.js_add_cart_variants[data-attribute_value_ids]").each(function () {
+ var $el = $(this);
+ $products_dom.push($el);
+ _.each($el.data("attribute_value_ids"), function (values) {
+ product_ids.push(values[0]);
+ });
+ });
+
+ // trigger input change to validate fields and display custom prices
+ $('.js_add_cart_variants', $modal).each(function () {
+ $('input.js_variant_change, select.js_variant_change', this).first().trigger('change');
+ });
+ }
+});
+return OptionalProductsModal;
+}); \ No newline at end of file
diff --git a/addons/sale/static/src/js/product_configurator_utils.js b/addons/sale/static/src/js/product_configurator_utils.js
new file mode 100644
index 00000000000..c97595075c4
--- /dev/null
+++ b/addons/sale/static/src/js/product_configurator_utils.js
@@ -0,0 +1,272 @@
+odoo.define('sale.product_configurator_utils', function (require) {
+ 'use strict';
+ var core = require('web.core');
+ var utils = require('web.utils');
+ var ajax = require('web.ajax');
+ var _t = core._t;
+
+ /**
+ * returns the formatted price
+ * @param {float} price
+ */
+ function _priceToStr (price) {
+ var l10n = _t.database.parameters;
+ var precision = 2;
+
+ if ($(".decimal_precision").length) {
+ precision = parseInt($(".decimal_precision").last().data('precision'));
+ }
+ var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.');
+ formatted[0] = utils.insert_thousand_seps(formatted[0]);
+ return formatted.join(l10n.decimal_point);
+ }
+
+ /**
+ * When a product is added or when the quantity is changed,
+ * we need to refresh the total price row
+ * TODO (awa): add a container context to avoid global selectors ?
+ */
+ function computePriceTotal (){
+ if($('.js_price_total').length){
+ var price = 0;
+ $('.js_product.in_cart').each(function(){
+ var quantity = parseInt($('input[name="add_qty"]').first().val());
+ price += parseFloat($(this).find('.js_raw_price').html()) * quantity;
+ });
+
+ $('.js_price_total .oe_currency_value').html(_priceToStr(parseFloat(price)));
+ }
+ }
+
+ function _updateProductImage ($productContainer, product_id, imagesSize) {
+ var $img;
+ var imageSrc = '/web/image?model=product.product&id={0}&field={1}'
+ .replace("{0}", product_id)
+ .replace("{1}", imagesSize ? ('image_' + imagesSize) : 'image');
+ if ($productContainer.find('#o-carousel-product').length) {
+ $img = $productContainer.find('img.js_variant_img');
+ $img.attr("src", imageSrc);
+ $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id)
+ .data('oe-model', 'product.product').data('oe-id', product_id);
+
+ var $thumbnail = $productContainer.find('img.js_variant_img_small');
+ if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed
+ $thumbnail.attr("src", "/web/image/product.product/" + product_id + "/image/90x90");
+ $('.carousel').carousel(0);
+ }
+ }
+ else {
+ $img = $productContainer.find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img, span.variant_image img');
+ $img.attr("src", imageSrc);
+ $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id)
+ .data('oe-model', 'product.product').data('oe-id', product_id);
+ }
+ // reset zooming constructs
+ $img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src'));
+ if ($img.data('zoomOdoo') !== undefined) {
+ $img.data('zoomOdoo').isReady = false;
+ }
+ }
+
+ /**
+ * When a variant is changed, this will compute:
+ * - If the selected combination is available or not
+ * - The extra price if applicable
+ * - The display name of the product ("Customizable desk (White, Steel)")
+ * TODO (awa): change ugly [0], [1], [2], ... to a dict
+ */
+ function onChangeVariant (ev, imagesSize) {
+ var $parent = $(ev.target).closest('.js_product');
+ var $ul = $parent.find('.js_add_cart_variants');
+ var $product_id = $parent.find('.product_id').first();
+ var $product_display_name = $parent.find('.product_display_name').first();
+ var $product_raw_price = $parent.find('.js_raw_price').first();
+ var $price = $parent.find(".oe_price:first .oe_currency_value");
+ var $default_price = $parent.find(".oe_default_price:first .oe_currency_value");
+ var $optional_price = $parent.find(".oe_optional:first .oe_currency_value");
+ var variant_ids = $ul.data("attribute_value_ids");
+ var values = [];
+ var unchanged_values = $parent.find('div.oe_unchanged_value_ids').data('unchanged_value_ids') || [];
+
+ _.each($parent.find('input.js_variant_change:checked, select.js_variant_change'), function (el) {
+ values.push(+$(el).val());
+ });
+ values = values.concat(unchanged_values);
+ var list_variant_id = parseInt($parent.find('input.js_product_change:checked').val());
+
+ $parent.find("label").removeClass("text-muted css_not_available");
+
+ var product_id = false;
+ var display_name = false;
+ var product_raw_price = false;
+ for (var k in variant_ids) {
+ if (_.isEmpty(_.difference(variant_ids[k][1], values)) || variant_ids[k][0] === list_variant_id) {
+ $price.html(_priceToStr(variant_ids[k][2]));
+ $default_price.html(_priceToStr(variant_ids[k][3]));
+ if (variant_ids[k][3]-variant_ids[k][2]>0.01) {
+ $default_price.closest('.oe_website_sale').addClass("discount");
+ $optional_price.closest('.oe_optional').show().css('text-decoration', 'line-through');
+ $default_price.parent().removeClass('d-none');
+ } else {
+ $optional_price.closest('.oe_optional').hide();
+ $default_price.parent().addClass('d-none');
+ }
+ product_id = variant_ids[k][0];
+ product_raw_price = variant_ids[k][2];
+ display_name = variant_ids[k][4];
+ _updateProductImage($(ev.currentTarget).closest('tr.js_product, .oe_website_sale, .o_product_configurator'), product_id, imagesSize);
+ break;
+ }
+ }
+
+ _.each($parent.find("input.js_variant_change:radio, select.js_variant_change"), function (elem) {
+ var $input = $(elem);
+ var id = +$input.val();
+ var values = [id];
+
+ _.each($parent.find("ul:not(:has(input.js_variant_change[value='" + id + "'])) input.js_variant_change:checked, select.js_variant_change"), function (elem) {
+ values.push(+$(elem).val());
+ });
+
+ for (var k in variant_ids) {
+ if (!_.difference(values, variant_ids[k][1]).length) {
+ return;
+ }
+ }
+ $input.closest("label").addClass("css_not_available");
+ $input.find("option[value='" + id + "']").addClass("css_not_available");
+ });
+
+ if (product_id) {
+ $parent.removeClass("css_not_available");
+ $product_id.val(product_id);
+ $product_display_name.val(display_name);
+ $product_raw_price.html(product_raw_price);
+ $parent.parents('.modal').find('.o_sale_product_configurator_add').removeClass("disabled");
+
+ var $add_to_cart_button = $parent.find("#add_to_cart");
+ if(!$add_to_cart_button.hasClass('out_of_stock')){
+ $add_to_cart_button.removeClass("disabled");
+ }
+ } else {
+ $parent.addClass("css_not_available");
+ $product_id.val(0);
+ $product_display_name.val('');
+ $parent.find("#add_to_cart").addClass("disabled");
+ $parent.parents('.modal').find('.o_sale_product_configurator_add').addClass("disabled");
+ }
+
+ computePriceTotal();
+ }
+
+ /**
+ * Highlight selected color
+ */
+ function _onChangeColorAttribute (ev) {
+ var $parent = $(ev.target).closest('.js_product');
+ $parent.find('.css_attribute_color')
+ .removeClass("active")
+ .filter(':has(input:checked)')
+ .addClass("active");
+ }
+
+ /**
+ * Hack to add and remove from cart with json
+ */
+ function onClickAddCartJSON (ev) {
+ ev.preventDefault();
+ var $link = $(ev.currentTarget);
+ var $input = $link.closest('.input-group').find("input");
+ var product_id = +$input.closest('*:has(input[name="product_id"])').find('input[name="product_id"]').val();
+ var min = parseFloat($input.data("min") || 0);
+ var max = parseFloat($input.data("max") || Infinity);
+ var quantity = ($link.has(".fa-minus").length ? -1 : 1) + parseFloat($input.val() || 0, 10);
+ var new_qty = quantity > min ? (quantity < max ? quantity : max) : min;
+ // if they are more of one input for this product (eg: option modal)
+ $('input[name="'+$input.attr("name")+'"]').add($input).filter(function () {
+ var $prod = $(this).closest('*:has(input[name="product_id"])');
+ return !$prod.length || +$prod.find('input[name="product_id"]').val() === product_id;
+ }).val(new_qty).change();
+ return false;
+ }
+
+ /**
+ * When the quantity is changed, we need to query the new price of the product.
+ * Based on the pricelist, the price might change when quantity exceeds X
+ */
+ function onChangeAddQuantity (ev, isWebsite, pricelistId, no_stock_check) {
+ var $parent;
+ if($(ev.currentTarget).closest('form').length > 0){
+ $parent = $(ev.currentTarget).closest('form');
+ } else if($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){
+ $parent = $(ev.currentTarget).closest('.oe_optional_products_modal');
+ } else {
+ $parent = $(ev.currentTarget).closest('.o_product_configurator');
+ }
+
+ var qty = $parent.find('input[name="add_qty"]').val();
+ var productIDs = [];
+ $parent.find(".js_product .js_add_cart_variants").each(function () {
+ _.each($(this).data("attribute_value_ids"), function(entry){
+ productIDs.push(entry[0]);
+ });
+ });
+
+ if (productIDs.length) {
+ ajax.jsonRpc('/product_configurator/get_unit_price' + (isWebsite ? '_website' : ''), 'call', {
+ product_ids: productIDs,
+ add_qty: parseInt(qty),
+ pricelist_id: pricelistId
+ }).then(function (data) {
+ $parent.find(".js_product .js_add_cart_variants").each(function () {
+ var currentAttributesData = $(this).data("attribute_value_ids");
+ for (var j = 0 ; j < currentAttributesData.length ; j++) {
+ // update the price of the product (index "2") based on its id (index "0")
+ currentAttributesData[j][2] = data[currentAttributesData[j][0]];
+ }
+ $(this).trigger('change', [no_stock_check]);
+
+ computePriceTotal();
+ });
+ });
+ }
+ }
+
+ /**
+ * Adds all the necessary events to handle:
+ * - Product variant changes
+ * - Color changes
+ * - Product price based on pricelist and quantity
+ * - Total price
+ * ...
+ */
+ function addConfiguratorEvents ($container, imagesSize, isWebsite, pricelistId) {
+ $container
+ .find('.css_attribute_color input')
+ .change(_onChangeColorAttribute);
+
+ $container
+ .find('input.js_variant_change, select.js_variant_change, input.js_product_change, [data-attribute_value_ids]')
+ .change(function(ev, no_stock_check){
+ onChangeVariant(ev, imagesSize, no_stock_check);
+ });
+
+ $container
+ .find('input[name="add_qty"]')
+ .change(function(ev){
+ onChangeAddQuantity(ev, isWebsite, pricelistId);
+ });
+
+ $container
+ .find('button.js_add_cart_json')
+ .click(onClickAddCartJSON);
+ }
+
+ return {
+ addConfiguratorEvents: addConfiguratorEvents,
+ onChangeVariant: onChangeVariant,
+ onChangeAddQuantity: onChangeAddQuantity,
+ onClickAddCartJSON: onClickAddCartJSON,
+ computePriceTotal: computePriceTotal
+ };
+}); \ No newline at end of file
diff --git a/addons/sale/static/src/scss/product_configurator.scss b/addons/sale/static/src/scss/product_configurator.scss
new file mode 100644
index 00000000000..91970f144e7
--- /dev/null
+++ b/addons/sale/static/src/scss/product_configurator.scss
@@ -0,0 +1,178 @@
+.css_attribute_color {
+ display: inline-block;
+ border: 1px solid #999999;
+ text-align: center;
+
+ input {
+ margin: 8px;
+ height: 13px;
+ opacity: 0;
+ }
+
+ &.active {
+ border: 3px ridge #66ee66;
+ }
+
+ &.active input {
+ margin: 6px;
+ }
+}
+
+.css_not_available_msg {
+ display: none;
+}
+
+.css_not_available.js_product {
+ > *:nth-child(4) > * {
+ display: none;
+ }
+
+ .css_quantity,
+ .product_price {
+ display: none;
+ }
+
+ .css_not_available_msg {
+ display: block;
+ }
+}
+
+.css_quantity {
+ max-width: 125px;
+
+ input[name="add_qty"] {
+ text-align: center;
+ }
+}
+
+option.css_not_available {
+ color: #ccc;
+}
+
+label.css_not_available {
+ opacity: 0.6;
+}
+
+label.css_attribute_color.css_not_available {
+ opacity: 1;
+ background-image: url("/website_sale/static/src/img/redcross.png");
+ background-size: cover;
+}
+
+.o_product_configurator {
+ .product_detail_img {
+ max-height: 240px;
+ }
+}
+
+.configurator_container {
+ ul ul {
+ margin-left: 1.5rem;
+ }
+}
+
+.oe_optional_products_modal {
+ .table-striped tbody tr:nth-of-type(odd) {
+ background-color: rgba(0, 0, 0, 0.025);
+ }
+
+ .o_total_row {
+ h5 {
+ padding-top: 0.4rem;
+ }
+ .js_price_total {
+ font-size: 1.5rem;
+ }
+ }
+}
+
+.js_product {
+ &.in_cart {
+ .js_add_cart_variants {
+ display: none;
+ }
+ }
+
+ select {
+ -webkit-appearance: menulist;
+ -moz-appearance: menulist;
+ appearance: menulist;
+ background-image: none;
+ }
+
+ .remove-optionnal-item, .add-optionnal-item {
+ padding-left: 5px;
+ }
+
+ .td-product_name {
+ word-wrap: break-word;
+ }
+
+ .td-product_name {
+ min-width: 140px;
+ }
+
+ .td-img {
+ width: 100px;
+ }
+
+ .td-qty {
+ width: 130px;
+ a.input-group-addon {
+ background-color: transparent;
+ border: 0px;
+ }
+
+ .input-group {
+ display: inline-flex;
+ }
+ }
+ .td-action {
+ width: 30px;
+ }
+
+ .td-price,
+ .td-price-total {
+ width: 120px;
+ }
+
+ @include media-breakpoint-down(sm) {
+ .td-img,
+ .td-price-total {
+ display: none;
+ }
+
+ .td-qty {
+ width: 60px;
+ }
+
+ .td-price {
+ width: 80px;
+ }
+ }
+
+ @media (max-width: 476px) {
+ .td-qty {
+ width: 60px;
+ }
+
+ #modal_optional_products table thead,
+ .oe_cart table thead {
+ display: none;
+ }
+
+ #modal_optional_products table td.td-img,
+ .oe_cart table td.td-img {
+ display: none;
+ }
+ }
+}
+
+.o_total_row {
+ height: 50px;
+}
+
+.oe_striked_price {
+ text-decoration: line-through;
+ white-space: nowrap;
+} \ No newline at end of file
diff --git a/addons/sale/static/tests/product_configurator.test.js b/addons/sale/static/tests/product_configurator.test.js
new file mode 100644
index 00000000000..768722c6ac9
--- /dev/null
+++ b/addons/sale/static/tests/product_configurator.test.js
@@ -0,0 +1,140 @@
+odoo.define('sale.product.configurator.tests', function (require) {
+ "use strict";
+
+ var FormView = require('web.FormView');
+ var ProductConfiguratorFormView = require('sale.ProductConfiguratorFormView');
+ var testUtils = require('web.test_utils');
+ var ajax = require('web.ajax');
+ var createView = testUtils.createView;
+
+ var getArch = function(){
+ return '<form>' +
+ '<sheet>' +
+ '<field name="sale_order_line">' +
+ '<tree editable="top"><control>' +
+ '<create string="Add a product"/>' +
+ '<create string="Configure a product" context="{\'open_product_configurator\': \'true\'}"/>' +
+ '<create string="Add a section" context="{\'default_display_type\': \'line_section\'}"/>' +
+ '<create string="Add a note" context="{\'default_display_type\': \'line_note\'}"/>' +
+ '</control>' +
+ '<field name="product_id"/><field name="quantity"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>';
+ };
+
+ QUnit.module('Product Configurator', {
+ beforeEach: function () {
+ this.data = {
+ product_template: {
+ fields: {
+ id: {type: 'integer'}
+ },
+ records: [{
+ id: 42,
+ display_name: "Customizable Desk"
+ }]
+ },
+ product: {
+ fields: {
+ id: {type: 'integer'}
+ }
+ },
+ sale_order: {
+ fields: {
+ id: {type: 'integer'},
+ sale_order_line: {string: 'lines', type: 'one2many', relation: 'sale_order_line'},
+ }
+ },
+ sale_order_line: {
+ fields: {
+ product_id: {string: 'product', type: 'many2one', relation: 'product'},
+ quantity: {type: 'integer'}
+ }
+ },
+ sale_product_configurator: {
+ fields: {
+ product_template_id: {string: 'product', type: 'many2one', relation: 'product_template'}
+ },
+ records: [{
+ product_template_id: 42
+ }]
+ },
+ };
+ }
+ }, function(){
+ QUnit.test('click on "Configure a product" and check for form loading', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'sale_order',
+ data: this.data,
+ arch: getArch(),
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id') {
+ assert.ok(true);
+ return $.Deferred().then(_.constant(1));
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$("a:contains('Configure a product')").length, 1);
+
+ form.$("a:contains('Configure a product')").click();
+ });
+
+ QUnit.test('trigger_up the "add_record" event and checks that rows are correctly added to the list', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'sale_order',
+ data: this.data,
+ arch: getArch()
+ });
+
+ var list = form.renderer.allFieldWidgets[form.handle][0];
+
+ list.trigger_up('add_record', {
+ context: [{product_id: 1, quantity: 2}, {product_id: 2, quantity: 3}],
+ forceEditable: "bottom" ,
+ allowWarning: true
+ });
+
+ assert.strictEqual(list.$("tr.o_data_row").length, 2);
+ });
+
+ QUnit.test('Select a product in the list and check for template loading', function(assert){
+ assert.expect(1);
+
+ ajax.jsonRpc = function(route){
+ assert.ok(true);
+ return $.Deferred().then(_.constant($("<div></div>")));
+ };
+
+ var product_configurator_form = createView({
+ View: ProductConfiguratorFormView,
+ model: 'sale_product_configurator',
+ data: this.data,
+ arch:
+ '<form js_class="product_configurator_form">' +
+ '<group>' +
+ '<field name="product_template_id" class="oe_product_configurator_product_template_id" />' +
+ '</group>' +
+ '<footer>' +
+ '<button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>' +
+ '<button string="Cancel" class="btn-secondary" special="cancel"/>' +
+ '</footer>' +
+ '</form>',
+ });
+ var div = '<div>';
+ var $div = $(div).addClass('js_sale_order_pricelist_id').html(43);
+ product_configurator_form.$('.o_input').after($div);
+ product_configurator_form.$('.o_input').click();
+ $("ul.ui-autocomplete li a:contains('Customizable Desk')").mouseenter().click();
+ });
+ });
+});
diff --git a/addons/sale/views/assets.xml b/addons/sale/views/assets.xml
index af1d10f08c2..81b5c92abf6 100644
--- a/addons/sale/views/assets.xml
+++ b/addons/sale/views/assets.xml
@@ -3,10 +3,15 @@
<template id="assets_backend_inherit_sale" inherit_id="web.assets_backend" name="Sale backend assets">
<xpath expr="link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/sale_onboarding.scss"/>
+ <link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/product_configurator.scss"/>
</xpath>
<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_list_editable_renderer.js"></script>
+ <script type="text/javascript" src="/sale/static/src/js/product_configurator.js"></script>
+ <script type="text/javascript" src="/sale/static/src/js/product_configurator_utils.js"></script>
+ <script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script>
</xpath>
</template>
<template id="assets_frontend_inherit_sale" inherit_id="web.assets_frontend" name="Sale frontend assets">
@@ -17,4 +22,9 @@
<script type="text/javascript" src="/sale/static/src/js/sale_portal_sidebar.js"></script>
</xpath>
</template>
+ <template id="qunit_suite" name="product_configurator_tests" inherit_id="web.qunit_suite">
+ <xpath expr="//t[@t-set='head']" position="inside">
+ <script type="text/javascript" src="/sale/static/tests/product_configurator.test.js"></script>
+ </xpath>
+ </template>
</odoo>
diff --git a/addons/sale/views/product_attribute_views.xml b/addons/sale/views/product_attribute_views.xml
new file mode 100644
index 00000000000..781e3364bff
--- /dev/null
+++ b/addons/sale/views/product_attribute_views.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <!-- Product attribute -->
+
+ <record id="attribute_tree_view" model="ir.ui.view">
+ <field name="name">product.attribute.tree.type</field>
+ <field name="model">product.attribute</field>
+ <field name="inherit_id" ref="product.attribute_tree_view"></field>
+ <field name="arch" type="xml">
+ <field name="name" position="after">
+ <field name="type"/>
+ </field>
+ </field>
+ </record>
+
+ <record id="product_attribute_line_form" model="ir.ui.view">
+ <field name="name">product.attribute.line.form.inherit</field>
+ <field name="model">product.attribute.line</field>
+ <field name="inherit_id" ref="product.product_attribute_line_form"/>
+ <field name="priority" eval="8"/>
+ <field name="arch" type="xml">
+ <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after">
+ <field name="html_color"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="product_attribute_view_form" model="ir.ui.view">
+ <field name="name">product.attribute.form.inherit</field>
+ <field name="model">product.attribute</field>
+ <field name="inherit_id" ref="product.product_attribute_view_form"/>
+ <field name="priority" eval="8"/>
+ <field name="arch" type="xml">
+ <xpath expr="//group[@name='main_fields']" position="after">
+ <group>
+ <field name="type"/>
+ </group>
+ </xpath>
+ <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after">
+ <field name="html_color"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="product_product_attribute_value_view_tree_inherit" model="ir.ui.view">
+ <field name="name">product.product.attribute.value.view.tree.inherit</field>
+ <field name="model">product.product.attribute.value</field>
+ <field name="inherit_id" ref="product.product_product_attribute_value_view_tree"/>
+ <field name="arch" type="xml">
+ <field name="name" position="after">
+ <field name="html_color"/>
+ </field>
+ </field>
+ </record>
+
+ <record id="product_product_attribute_value_view_form_inherit" model="ir.ui.view">
+ <field name="name">product.product.attribute.value.view.form.inherit</field>
+ <field name="model">product.product.attribute.value</field>
+ <field name="inherit_id" ref="product.product_product_attribute_value_view_form"/>
+ <field name="arch" type="xml">
+ <field name="name" position="after">
+ <field name="html_color" readonly="True" />
+ </field>
+ </field>
+ </record>
+
+ <record id="variants_tree_view" model="ir.ui.view">
+ <field name="name">variants.template.tree</field>
+ <field name="model">product.attribute.value</field>
+ <field name="inherit_id" ref="product.variants_tree_view"/>
+ <field name="arch" type="xml">
+ <field name="name" position="after">
+ <field name="html_color"/>
+ </field>
+ </field>
+ </record>
+</odoo> \ No newline at end of file
diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml
index 3062f8b7814..fccc6ac8d53 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">
- Set product attributes (e.g. color, size) to sell variants
+ Configure your products with variants and select optional products
</div>
<div class="content-group" attrs="{'invisible': [('group_product_variant','=',False)]}">
<div class="mt16">
diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml
new file mode 100644
index 00000000000..3eb6170f019
--- /dev/null
+++ b/addons/sale/views/sale_product_configurator_templates.xml
@@ -0,0 +1,256 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <template id="optional_products_modal" name="Optional Products">
+ <div role="dialog" id="modal_optional_products" class="modal fade" tabindex="-1" aria-labelledby="myModalLabel">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <header class="modal-header">
+ <h4 class="modal-title" id="myModalLabel">Add to cart</h4>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">x</button>
+ </header>
+ <div class="modal-body">
+ <t t-call="sale.product_configurator_configure_optional_products" />
+ </div>
+ <footer class="modal-footer">
+ <a href="#" role="button" class="btn btn-secondary a-submit go-back js_goto_shop" aria-label="Continue shopping" title="Continue shopping"><i class="fa fa-chevron-left"></i> <span class="d-none d-md-inline">Continue Shopping</span><span class="d-inline d-md-none">Continue</span></a>
+ <a href="#" role="button" class="btn btn-primary float-right confirm a-submit" aria-label="Proceed to checkout" title="Proceed to checkout"><i class="fa fa-shopping-cart fa-fw"></i> <span class="d-none d-md-inline">Proceed to Checkout</span><span class="d-inline d-md-none">Checkout</span></a>
+ </footer>
+ </div>
+ </div>
+ </div>
+ </template>
+
+ <template id="product_configurator_configure" name="Configure">
+ <div class="js_product">
+ <input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/>
+ <div class="col-lg-12 text-center mt-5">
+ <div class="col-lg-5 d-inline-block align-top text-left">
+ <span t-field="product.image" t-options='{"widget": "image", "class": "d-block product_detail_img"}'/>
+ </div>
+ <div class="col-lg-1 d-inline-block"></div>
+ <div class="col-lg-5 d-inline-block text-left">
+ <t t-if="len(product.product_variant_ids) > 1">
+ <t t-call="sale.variants" />
+ </t>
+ <h2 class="oe_price product_id mt-3" style="white-space: nowrap;"
+ t-att-data-product-id="product.id"
+ t-esc="product.price if product.price else product.lst_price"
+ t-options='{
+ "widget": "monetary",
+ "display_currency": to_currency
+ }'/>
+ <div class="css_quantity input-group oe_website_spinner">
+ <div class="input-group-prepend">
+ <button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
+ <i class="fa fa-minus"></i>
+ </button>
+ </div>
+ <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/>
+ <div class="input-group-append">
+ <button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
+ <i class="fa fa-plus"></i>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+
+ <template id="product_configurator_configure_optional_products">
+ <table class="table table-striped table-sm">
+ <thead>
+ <tr>
+ <th class="td-img">Product</th>
+ <th></th>
+ <th class="text-center td-qty">Quantity</th>
+ <th class="text-center td-price">Price</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr class="js_product in_cart">
+ <td class='td-img'>
+ <span t-field="product.image_medium" t-options='{"widget": "image" }'/>
+ </td>
+ <td class='td-product_name'>
+ <strong t-field="product.display_name"/>
+ <div class="text-muted small">
+ <div t-field="product.description_sale"/>
+ <div class="js_attributes"/>
+ </div>
+ </td>
+ <td class="text-center td-qty">
+ <div class="css_quantity input-group oe_website_spinner">
+ <div class="input-group-prepend">
+ <button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
+ <i class="fa fa-minus"></i>
+ </button>
+ </div>
+ <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/>
+ <div class="input-group-append">
+ <button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
+ <i class="fa fa-plus"></i>
+ </button>
+ </div>
+ </div>
+ </td>
+ <td class="text-center td-price" name="price">
+ <ul class="d-none js_add_cart_variants" t-att-data-attribute_value_ids="json.dumps(main_product_attr_ids)"></ul>
+ <div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="main_product_attr_ids[0][1]" ></div>
+ <div t-attf-class="text-danger oe_default_price oe_striked_price {{ (compute_currency(product.lst_price) - product.price) &lt; 0.01 and 'd-none' }}"
+ t-field="product.lst_price"
+ t-options='{
+ "widget": "monetary",
+ "from_currency": product.currency_id,
+ "display_currency": to_currency
+ }'/>
+ <span class="oe_price product_id" style="white-space: nowrap;"
+ t-att-data-product-id="product.id"
+ t-esc="product.price"
+ t-options='{
+ "widget": "monetary",
+ "display_currency": to_currency
+ }'/>
+ <span class="js_raw_price d-none" t-esc="product.price" />
+ </td>
+ </tr>
+ <tr class="o_total_row">
+ <td colspan="2"></td>
+ <td class="text-right"><h5>Total:</h5></td>
+ <td class="text-center">
+ <span class="js_price_total font-weight-bold" style="white-space: nowrap;"
+ t-att-data-product-id="product.id"
+ t-esc="product.price"
+ t-options='{
+ "widget": "monetary",
+ "display_currency": to_currency
+ }'/>
+ </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" />
+ </tbody>
+ </table>
+ </template>
+
+ <template id="optional_product_items">
+ <t t-foreach="product.optional_product_ids" t-as="product">
+ <t t-if="len(product.get_filtered_variants(reference_product)) > 0">
+ <tr class="js_product">
+ <td class="td-img">
+ <input type="hidden" class="product_template_id" t-att-value="product.id"/>
+ <input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/>
+ <input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="product.product_variant_id.display_name if len(product.product_variant_ids) == 1 else ''"/>
+ <span t-field="product.image_small" t-options='{"widget": "image"}' class="variant_image"/>
+ </td>
+ <td class='td-product_name'>
+ <div class="float-left">
+ <strong class="product-name" t-field="product.name"/>
+ <div class="text-muted small" t-field="product.description_sale"/>
+ </div>
+ </td>
+ <td class="td-qty">
+ <div class="float-right">
+ <t t-call="sale.variants"/>
+ </div>
+ </td>
+ <td class="text-center td-price">
+ <span class="text-danger oe_default_price oe_optional"
+ t-field="product.lst_price"
+ t-options='{
+ "widget": "monetary",
+ "from_currency": product.currency_id,
+ "display_currency": to_currency
+ }'/>
+ <span class="oe_price" style="white-space: nowrap;"
+ t-field="product.price"
+ t-options='{
+ "widget": "monetary",
+ "display_currency": to_currency
+ }'/>
+ <span class="js_raw_price d-none" t-esc="product.price" />
+ <p class="css_not_available_msg bg-danger" style="padding: 5px;">Option not available</p>
+
+ <input type="hidden" class="js_optional_same_quantity" t-attf-name="optional-add-#{product.id}" value="0"/>
+ <a role="button" href="#" class="js_add"><i class="fa fa-shopping-cart add-optionnal-item"></i></a>
+ <span class="js_remove d-none">
+ <span class="optional_product_quantity"><span class="js_item add_qty">1 </span><span class="js_items d-none">5 </span> <span t-field="product.uom_id.sudo().name"/><br/></span>
+ <a role="button" href="#" class="js_remove"><i class="fa fa-trash-o remove-optionnal-item"></i></a>
+ </span>
+ </td>
+ </tr>
+ </t>
+ </t>
+ </template>
+
+
+
+ <template id="variants">
+ <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product, reference_product, pricelist)"/>
+ <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)">
+ <t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id">
+ <li t-if="len(variant_id.product_value_ids) > 1">
+
+ <strong t-field="variant_id.attribute_id.name"/>
+
+ <t t-if="variant_id.attribute_id.type == 'select'">
+ <select t-attf-class="form-control #{'js_variant_change' if variant_id.attribute_id.create_variant else ''}" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
+ <t t-foreach="variant_id.product_value_ids" t-as="value_id">
+ <option t-att-value="value_id.id">
+ <span t-field="value_id.name"/>
+ <span t-if="value_id.price_extra">
+ <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{
+ "widget": "monetary",
+ "from_currency": product.currency_id,
+ "display_currency": to_currency or website.currency_id
+ }'/>
+ </span>
+ </option>
+ </t>
+ </select>
+ </t>
+
+ <t t-if="variant_id.attribute_id.type == 'radio'">
+ <ul class="list-unstyled">
+ <t t-set="inc" t-value="0"/>
+ <t t-foreach="variant_id.product_value_ids" t-as="value_id">
+ <li class="form-group js_attribute_value" style="margin: 0;">
+ <label class="col-form-label" style="margin: 0 20px;">
+ <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" t-att-checked="'checked' if not inc else None" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" t-att-value="value_id.id" style="vertical-align: top; margin-right: 10px;"/>
+ <span t-field="value_id.name"/>
+ <span class="badge badge-pill" t-if="value_id.price_extra">
+ <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{
+ "widget": "monetary",
+ "from_currency": product.currency_id,
+ "display_currency": to_currency or website.currency_id
+ }'/>
+ </span>
+ </label>
+ </li>
+ <t t-set="inc" t-value="inc+1"/>
+ </t>
+ </ul>
+ </t>
+
+ <t t-if="variant_id.attribute_id.type == 'color'">
+ <ul class="list-inline">
+ <t t-set="inc" t-value="0"/>
+ <li t-foreach="variant_id.product_value_ids" t-as="value_id" class="list-inline-item">
+ <label t-attf-style="background-color:#{value_id.html_color or value_id.name}"
+ t-attf-class="css_attribute_color #{'active' if not inc else ''}">
+ <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None"
+ t-att-checked="'checked' if not inc else None"
+ 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"/>
+ </label>
+ <t t-set="inc" t-value="inc+1"/>
+ </li>
+ </ul>
+ </t>
+
+ </li>
+ </t>
+ </ul>
+ </template>
+</odoo> \ No newline at end of file
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index 21876534f7e..3dc714bb33c 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -72,6 +72,24 @@
</field>
</record>
+ <record id="product_template_form_view" model="ir.ui.view">
+ <field name="name">product.template.product.website.form</field>
+ <field name="model">product.template</field>
+ <field name="inherit_id" ref="product.product_template_form_view"/>
+ <field name="arch" type="xml">
+ <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>
+
<menuitem id="product_menu_catalog" name="Products" action="product_template_action" parent="sale_menu_root" sequence="4" groups="sales_team.group_sale_salesman"/>
<menuitem id="menu_product" name="Product Variants" parent="product_menu_catalog" sequence="2" groups="base.group_no_one" active="False"/>
<menuitem action="product_template_action" id="menu_product_template_action" parent="product_menu_catalog" sequence="1" active="False"/>
@@ -272,6 +290,7 @@
<field name="validity_date" attrs="{'invisible': [('state', 'in', ['sale', 'done'])]}"/>
<field name="confirmation_date" attrs="{'invisible': [('state', 'in', ['draft', 'sent', 'cancel'])]}"/>
<field name="pricelist_id" groups="product.group_sale_pricelist"/>
+ <field name="pricelist_id_id" invisible="1" class="js_sale_order_pricelist_id" />
<field name="currency_id" invisible="1"/>
<field name="payment_term_id" options="{'no_create': True}"/>
</group>
@@ -377,6 +396,7 @@
>
<control>
<create string="Add a product"/>
+ <create string="Configure a product" context="{'open_product_configurator': 'true'}" groups="product.group_product_variant"/>
<create string="Add a section" context="{'default_display_type': 'line_section'}"/>
<create string="Add a note" context="{'default_display_type': 'line_note'}"/>
</control>
@@ -1001,7 +1021,7 @@
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='email_template_and_project']" position="before">
- <group string="Invoicing" name="invoicing">
+ <group string="Invoicing" name="invoicing" invisible="1">
<field name="invoice_policy" widget="radio"/>
<field name="service_type" widget="radio" invisible="True"/>
<field name="hide_expense_policy" invisible="1"/>
diff --git a/addons/sale/wizard/__init__.py b/addons/sale/wizard/__init__.py
index 09f069f85ed..145cce89c1e 100644
--- a/addons/sale/wizard/__init__.py
+++ b/addons/sale/wizard/__init__.py
@@ -3,3 +3,4 @@
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/wizard/sale_product_configurator.py b/addons/sale/wizard/sale_product_configurator.py
new file mode 100644
index 00000000000..6324b8d5ee6
--- /dev/null
+++ b/addons/sale/wizard/sale_product_configurator.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields
+
+
+class SaleProductConfigurator(models.TransientModel):
+ _name = 'sale.product.configurator'
+
+ product_template_id = fields.Many2one(
+ 'product.template', string="Product",
+ required=True, domain=['|', ('attribute_line_ids', '!=', False), ('optional_product_ids', '!=', False)])
diff --git a/addons/sale/wizard/sale_product_configurator_views.xml b/addons/sale/wizard/sale_product_configurator_views.xml
new file mode 100644
index 00000000000..9b29c3f9183
--- /dev/null
+++ b/addons/sale/wizard/sale_product_configurator_views.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="sale_product_configurator_view_form" model="ir.ui.view">
+ <field name="name">sale.product.configurator.view.form</field>
+ <field name="model">sale.product.configurator</field>
+ <field name="arch" type="xml">
+ <form js_class="product_configurator_form">
+ <group>
+ <field name="product_template_id" class="oe_product_configurator_product_template_id" />
+ </group>
+ <footer>
+ <button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>
+ <button string="Cancel" class="btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+</odoo> \ No newline at end of file
diff --git a/addons/sale_management/__manifest__.py b/addons/sale_management/__manifest__.py
index 98f77d20956..30c6a991371 100644
--- a/addons/sale_management/__manifest__.py
+++ b/addons/sale_management/__manifest__.py
@@ -39,6 +39,8 @@ The Dashboard for the Sales Manager will include
'depends': ['sale', 'digest'],
'data': [
'security/sale_management_security.xml',
+ 'views/assets.xml',
+ 'data/sale_order_template_data.xml',
'views/sale_portal_templates.xml',
'views/sale_order_template_views.xml',
'security/ir.model.access.csv',
diff --git a/addons/sale_management/static/tests/product_configurator_test_ui.js b/addons/sale_management/static/tests/product_configurator_test_ui.js
new file mode 100644
index 00000000000..7ae3a7917b0
--- /dev/null
+++ b/addons/sale_management/static/tests/product_configurator_test_ui.js
@@ -0,0 +1,66 @@
+odoo.define('sale.product_configurator_tour', function(require) {
+ "use strict";
+
+ var tour = require('web_tour.tour');
+
+ tour.register('sale_product_configurator_tour', {
+ url: "/web",
+ test: true,
+ }, [{
+ trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"], .oe_menu_toggler[data-menu-xmlid="sale.sale_menu_root"]'
+ }, {
+ trigger: ".o_list_button_add",
+ extra_trigger: ".o_sale_order"
+ }, {
+ trigger: "a:contains('Configure a product')"
+ }, {
+ trigger: '.o_product_configurator .o_input_dropdown input',
+ run: 'click'
+ }, {
+ trigger: 'li a:contains("Customizable Desk")',
+ in_modal: false,
+ extra_trigger: 'ul.ui-autocomplete',
+ run: 'click'
+ }, {
+ trigger: '.configurator_container span:contains("Aluminium")',
+ run: 'click'
+ }, {
+ trigger: ".o_sale_product_configurator_add",
+ run: 'click'
+ }, {
+ trigger: 'span:contains("Aluminium")',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+ }, {
+ trigger: '.js_product:not(.in_cart) .js_add',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+ }, {
+ trigger: '.js_product:not(.in_cart) .js_add', // not a mistake, this adds the option's option
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+ }, {
+ trigger: '.a-submit:not(.js_goto_shop)',
+ extra_trigger: '.oe_optional_products_modal',
+ run: 'click'
+ },
+ // check that 3 products were added to the SO
+ {
+ trigger: 'td.o_data_cell:contains("[FURN_0098] Customizable Desk (White, Aluminium)")',
+ extra_trigger: 'div[name="order_line"]',
+ in_modal: false,
+ run: function(){}
+ }, {
+ trigger: 'td.o_data_cell:contains("[E-COM13] Conference Chair (Aluminium)")',
+ extra_trigger: 'div[name="order_line"]',
+ in_modal: false,
+ run: function(){}
+ }, {
+ trigger: 'td.o_data_cell:contains("Chair floor protection")',
+ extra_trigger: 'div[name="order_line"]',
+ in_modal: false,
+ run: function(){}
+ }]);
+
+ });
+ \ No newline at end of file
diff --git a/addons/sale_management/views/assets.xml b/addons/sale_management/views/assets.xml
new file mode 100644
index 00000000000..a55eeb971ca
--- /dev/null
+++ b/addons/sale_management/views/assets.xml
@@ -0,0 +1,8 @@
+<?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"/>
+ </xpath>
+ </template>
+</odoo> \ No newline at end of file
diff --git a/addons/sale_management/views/sale_management_views.xml b/addons/sale_management/views/sale_management_views.xml
index 0d5615a2481..c46f8f9fb36 100644
--- a/addons/sale_management/views/sale_management_views.xml
+++ b/addons/sale_management/views/sale_management_views.xml
@@ -108,6 +108,15 @@
<xpath expr="//group[@name='description']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
+ <xpath expr="//group[@name='sale']" position="attributes">
+ <attribute name="invisible">0</attribute>
+ </xpath>
+ <xpath expr="//group[@name='invoicing']" position="attributes">
+ <attribute name="invisible">0</attribute>
+ </xpath>
+ <xpath expr="//group[@name='description']" position="attributes">
+ <attribute name="invisible">0</attribute>
+ </xpath>
</field>
</record>
diff --git a/addons/sale_stock/__manifest__.py b/addons/sale_stock/__manifest__.py
index 73b46286776..b78b72d927c 100644
--- a/addons/sale_stock/__manifest__.py
+++ b/addons/sale_stock/__manifest__.py
@@ -19,7 +19,7 @@ Preferences
* Incoterms: International Commercial terms
""",
- 'depends': ['sale_management', 'stock_account'],
+ 'depends': ['sale', 'stock_account'],
'data': [
'security/sale_stock_security.xml',
'security/ir.model.access.csv',
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index ab749b59927..6ac44761c11 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -262,8 +262,8 @@ class SaleOrderLine(models.Model):
if float_compare(product.virtual_available, product_qty, precision_digits=precision) == -1:
is_available = self._check_routing()
if not is_available:
- message = _('You plan to sell %s %s but you only have %s %s available in %s warehouse.') % \
- (self.product_uom_qty, self.product_uom.name, product.virtual_available, product.uom_id.name, self.order_id.warehouse_id.name)
+ message = _('You plan to sell %s %s of %s but you only have %s %s available in %s warehouse.') % \
+ (self.product_uom_qty, self.product_uom.name, self.product_id.name, product.virtual_available, product.uom_id.name, self.order_id.warehouse_id.name)
# We check if some products are available in other warehouses.
if float_compare(product.virtual_available, self.product_id.virtual_available, precision_digits=precision) == -1:
message += _('\nThere are %s %s available across all warehouses.\n\n') % \
diff --git a/addons/stock/security/ir.model.access.csv b/addons/stock/security/ir.model.access.csv
index 5496ee0b24c..99ac4a55f32 100644
--- a/addons/stock/security/ir.model.access.csv
+++ b/addons/stock/security/ir.model.access.csv
@@ -73,5 +73,6 @@ access_stock_forecast_manager,report.stock.forecast.manager,model_report_stock_f
access_stock_scrap_user,stock.scrap.user,model_stock_scrap,stock.group_stock_user,1,1,1,0
access_product_attribute_manager,product.attribute manager,product.model_product_attribute,stock.group_stock_manager,1,1,1,1
access_product_attribute_value_manager,product.attribute manager value,product.model_product_attribute_value,stock.group_stock_manager,1,1,1,1
-access_product_attribute_price_manager,product.attribute manager price,product.model_product_attribute_price,stock.group_stock_manager,1,1,1,1
+access_product_product_attribute_manager,product.product.attribute manager value,product.model_product_product_attribute_value,stock.group_stock_manager,1,1,1,1
+access_product_attribute_filter_line_manager,product.attribute manager filter line,product.model_product_attribute_filter_line,stock.group_stock_manager,1,1,1,1
access_product_attribute_line_manager,product.attribute manager line,product.model_product_attribute_line,stock.group_stock_manager,1,1,1,1
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js
index 2ea40a2602b..37d2f22ef55 100644
--- a/addons/web/static/src/js/fields/abstract_field.js
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -496,6 +496,7 @@ var AbstractField = Widget.extend({
viewType: this.viewType,
doNotSetDirty: options && options.doNotSetDirty,
notifyChange: !options || options.notifyChange !== false,
+ allowWarning: options && options.allowWarning,
onSuccess: def.resolve.bind(def),
onFailure: def.reject.bind(def),
});
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 7a428923608..3f64c0249cb 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -1461,7 +1461,7 @@ var FieldOne2Many = FieldX2Many.extend({
// we don't want interference with the components upstream.
ev.stopPropagation();
- if (this.editable) {
+ if (this.editable || data.forceEditable) {
if (!this.activeActions.create) {
if (data.onFail) {
data.onFail();
@@ -1471,10 +1471,16 @@ var FieldOne2Many = FieldX2Many.extend({
this.trigger_up('edited_list', { id: this.value.id });
this._setValue({
operation: 'CREATE',
- position: this.editable,
+ position: this.editable || data.forceEditable,
context: data.context,
+ }, {
+ allowWarning: data.allowWarning
}).always(function () {
self.creatingRecord = false;
+ }).done(function(){
+ if(data.onSuccess){
+ data.onSuccess();
+ }
});
}
} else {
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index ac7187c3874..84e900021e8 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -1344,6 +1344,7 @@ var BasicModel = AbstractModel.extend({
parentID: list.id,
position: position,
viewType: list.viewType,
+ allowWarning: options && options.allowWarning
};
return this._makeDefaultRecord(list.model, params).then(function (id) {
list._changes.push({operation: 'ADD', id: id, position: position, isNew: true});
@@ -1395,7 +1396,7 @@ var BasicModel = AbstractModel.extend({
for (var fieldName in changes) {
field = record.fields[fieldName];
if (field.type === 'one2many' || field.type === 'many2many') {
- defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType));
+ defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType, options.allowWarning));
} else if (field.type === 'many2one' || field.type === 'reference') {
defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName]));
} else {
@@ -1724,7 +1725,7 @@ var BasicModel = AbstractModel.extend({
* main viewType from the record
* @returns {Deferred}
*/
- _applyX2ManyChange: function (record, fieldName, command, viewType) {
+ _applyX2ManyChange: function (record, fieldName, command, viewType, allowWarning) {
if (command.operation === 'TRIGGER_ONCHANGE') {
// the purpose of this operation is to trigger an onchange RPC, so
// there is no need to apply any change on the record (the changes
@@ -1819,6 +1820,7 @@ var BasicModel = AbstractModel.extend({
var options = {
context: command.context,
position: command.position,
+ allowWarning: allowWarning
};
def = this._addX2ManyDefaultRecord(list, options).then(function (id) {
if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) {
diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js
index cf67b9dd18d..343730cc7a4 100644
--- a/addons/web/static/src/js/views/field_manager_mixin.js
+++ b/addons/web/static/src/js/views/field_manager_mixin.js
@@ -46,7 +46,7 @@ var FieldManagerMixin = {
*/
_applyChanges: function (dataPointID, changes, event) {
var self = this;
- var options = _.pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType');
+ var options = _.pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType', 'allowWarning');
return this.model.notifyChanges(dataPointID, changes, options)
.then(function (result) {
if (event.data.force_save) {
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index 33224a0fec2..841a8e7d4d8 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -61,7 +61,7 @@ ListRenderer.include({
}
_.each(child.children, function (child) {
- if (child.tag !== 'create') {
+ if (child.tag !== 'create' || child.attrs.invisible) {
return;
}
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 10027f17655..3f57e1d85b4 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -12,6 +12,7 @@ from odoo.addons.payment.controllers.portal import PaymentProcessing
from odoo.addons.website.controllers.main import QueryURL
from odoo.exceptions import ValidationError
from odoo.addons.website.controllers.main import Website
+from odoo.addons.sale.controllers.product_configurator import ProductConfiguratorController
from odoo.addons.website_form.controllers.main import WebsiteForm
from odoo.osv import expression
@@ -130,7 +131,7 @@ class Website(Website):
return views
-class WebsiteSale(http.Controller):
+class WebsiteSale(ProductConfiguratorController):
def _get_compute_currency_and_context(self):
pricelist_context = dict(request.env.context)
@@ -147,31 +148,6 @@ class WebsiteSale(http.Controller):
return compute_currency, pricelist_context, pricelist
- def get_attribute_value_ids(self, product):
- """ list of selectable attributes of a product
-
- :return: list of product variant description
- (variant id, [visible attribute ids], variant price, variant sale price)
- """
- # product attributes with at least two choices
- quantity = product._context.get('quantity') or 1
- product = product.with_context(quantity=quantity)
-
- visible_attrs_ids = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id').ids
- to_currency = request.website.get_current_pricelist().currency_id
- attribute_value_ids = []
- for variant in product.product_variant_ids:
- if to_currency != product.currency_id:
- price = variant.currency_id._convert(
- variant.website_public_price, to_currency,
- request.env.user.company_id, fields.Date.today()
- ) / quantity
- else:
- price = variant.website_public_price / quantity
- visible_attribute_ids = [v.id for v in variant.attribute_value_ids if v.attribute_id.id in visible_attrs_ids]
- attribute_value_ids.append([variant.id, visible_attribute_ids, variant.website_price / quantity, price])
- return attribute_value_ids
-
def _get_search_order(self, post):
# OrderBy will be parsed in orm and so no direct sql injection
# id is added to be sure that order is a unique sort key
@@ -344,7 +320,8 @@ class WebsiteSale(http.Controller):
'categories': categs,
'main_object': product,
'product': product,
- 'get_attribute_value_ids': self.get_attribute_value_ids,
+ 'optional_product_ids': [p.with_context({'active_id': p.id}) for p in product.optional_product_ids],
+ 'get_attribute_value_ids': self._get_attribute_value_ids,
}
return request.render("website_sale.product", values)
@@ -1005,11 +982,6 @@ class WebsiteSale(http.Controller):
ret = self.order_2_return_dict(order)
return ret
- @http.route(['/shop/get_unit_price'], type='json', auth="public", methods=['POST'], website=True)
- def get_unit_price(self, product_ids, add_qty, **kw):
- products = request.env['product.product'].with_context({'quantity': add_qty}).browse(product_ids)
- return {product.id: product.website_price / add_qty for product in products}
-
# ------------------------------------------------------
# Edit
# ------------------------------------------------------
@@ -1097,3 +1069,49 @@ class WebsiteSale(http.Controller):
states=[(st.id, st.name, st.code) for st in country.get_website_sale_states(mode=mode)],
phone_code=country.phone_code
)
+
+ @http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False)
+ def cart_options_update_json(self, product_id, add_qty=1, set_qty=0, goto_shop=None, lang=None, **kw):
+ if lang:
+ request.website = request.website.with_context(lang=lang)
+
+ order = request.website.sale_get_order(force_create=1)
+ optional_product_ids = []
+ for k, v in kw.items():
+ if "optional-product-" in k and int(kw.get(k.replace("product", "add"))):
+ optional_product_ids.append(int(v))
+
+ attributes = self._filter_attributes(**kw)
+
+ value = {}
+ if add_qty or set_qty:
+ value = order._cart_update(
+ product_id=int(product_id),
+ add_qty=add_qty,
+ set_qty=set_qty,
+ attributes=attributes,
+ optional_product_ids=optional_product_ids
+ )
+
+ # options have all time the same quantity
+ for option_id in optional_product_ids:
+ order._cart_update(
+ product_id=option_id,
+ set_qty=value.get('quantity'),
+ attributes=attributes,
+ linked_line_id=value.get('line_id')
+ )
+
+ return str(order.cart_quantity)
+
+ @http.route(['/product_configurator/show_optional_products_website'], type='json', auth="public", methods=['POST'], website=True)
+ def show_optional_products_website(self, product_id, **kw):
+ return self._show_optional_products(product_id, request.website.get_current_pricelist(), True, **kw)
+
+ @http.route(['/product_configurator/optional_product_items_website'], type='json', auth="public", methods=['POST'], website=True)
+ def optional_product_items_website(self, product_id, **kw):
+ return self._optional_product_items(product_id, request.website.get_current_pricelist(), **kw)
+
+ @http.route(['/product_configurator/get_unit_price_website'], type='json', auth="public", methods=['POST'], website=True)
+ def get_unit_price_website(self, product_ids, add_qty, **kw):
+ return self._get_unit_price(product_ids, add_qty, request.website.get_current_pricelist())
diff --git a/addons/website_sale/data/demo.xml b/addons/website_sale/data/demo.xml
index c296df7a7ef..d054dc9f68a 100644
--- a/addons/website_sale/data/demo.xml
+++ b/addons/website_sale/data/demo.xml
@@ -5,11 +5,9 @@
<field name="salesteam_id" ref="sales_team.salesteam_website_sales"/>
</record>
- <record id="product.product_attribute_2" model="product.attribute">
- <field name="type">color</field>
- </record>
- <record id="product.product_attribute_3" model="product.attribute">
- <field name="type">select</field>
+ <record id="sale.product_product_1_product_template" model="product.template">
+ <field name="website_sequence">100</field>
+ <field name="website_published" eval="True"/>
</record>
<record id="product.product_product_24" model="product.product">
@@ -223,12 +221,6 @@
<record id="product.product_delivery_02_product_template" model="product.template">
<field name="public_categ_ids" eval="[(6,0,[ref('public_category_desks')])]"/>
</record>
- <record id="product.product_attribute_value_3" model="product.attribute.value">
- <field name="html_color">#FFFFFF</field>
- </record>
- <record id="product.product_attribute_value_4" model="product.attribute.value">
- <field name="html_color">#000000</field>
- </record>
<record model="product.pricelist" id="product.list0">
<field name="selectable" eval="True" />
@@ -582,4 +574,49 @@
<!-- action_confirm for confirmation date -->
<function model="sale.order" name="action_confirm" eval="[[ref('website_sale_order_14')]]"/>
+ <function model="sale.order" name="_set_demo_create_date">
+ <value eval="[ref('website_sale_order_1'), ref('website_sale_order_2'), ref('website_sale_order_3'), ref('website_sale_order_4'),
+ ref('website_sale_order_5'), ref('website_sale_order_6'), ref('website_sale_order_7')]"/>
+ <value eval="(datetime.now()-timedelta(days=8)).strftime('%Y-%m-%d %H:%M:%S')"/>
+ </function>
+
+ <!-- Moved from deleted module "website_sale_option" -->
+ <record id="product_product_1" model="product.product">
+ <field name="name">Warranty</field>
+ <field name="default_code">SERV_125889</field>
+ <field name="list_price">20.0</field>
+ <field name="website_sequence">100</field>
+ <field name="website_published" eval="True"/>
+ <field name="type">service</field>
+ <field name="uom_id" ref="uom.product_uom_unit"/>
+ <field name="uom_po_id" ref="uom.product_uom_unit"/>
+ <field name="description_sale">Warranty, issued to the purchaser of an article by its manufacturer, promising to repair or replace it if necessary within a specified period of time.</field>
+ <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_5')])]"/>
+ <field name="categ_id" ref="product.product_category_3"/>
+ </record>
+ <record id="product_attribute_line_1" model="product.attribute.line">
+ <field name="product_tmpl_id" ref="product_product_1_product_template"/>
+ <field name="attribute_id" ref="product.product_attribute_3"/>
+ <field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_5'), ref('product.product_attribute_value_6')])]"/>
+ </record>
+ <record id="product_product_1_product_template" model="product.template">
+ <field name="attribute_line_ids" eval="[(6,0,[ref('product_attribute_line_1')])]"/>
+ <field name="public_categ_ids" eval="[(6,0,[ref('website_sale.services')])]"/>
+ </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'), ref('product_product_1_product_template')])]"/>
+ </record>
+
+ <!-- Handle automatically created product.product.attribute.value -->
+ <function model="ir.model.data" name="_update_xmlids">
+ <value model="base" eval="[{
+ 'xml_id': 'website_sale.product_product_attribute_value_1',
+ 'record': obj().env.ref('website_sale.product_attribute_line_1').product_value_ids[1],
+ 'noupdate': True,
+ }]"/>
+ </function>
+
+ <record id="product_product_attribute_value_1" model="product.product.attribute.value">
+ <field name="price_extra">18.00</field>
+ </record>
</odoo>
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index b4674d62ca2..44ae604e17c 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -217,20 +217,6 @@ class Product(models.Model):
return self.product_tmpl_id.website_publish_button()
-class ProductAttribute(models.Model):
- _inherit = "product.attribute"
-
- type = fields.Selection([('radio', 'Radio'), ('select', 'Select'), ('color', 'Color')], default='radio')
-
-
-class ProductAttributeValue(models.Model):
- _inherit = "product.attribute.value"
-
- html_color = fields.Char(string='HTML Color Index', oldname='color', help="Here you can set a "
- "specific HTML color index (e.g. #ff0000) to display the color on the website if the "
- "attibute type is 'Color'.")
-
-
class ProductImage(models.Model):
_name = 'product.image'
_description = 'Product Image'
diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py
index bae4d3faf6c..44a41472cbc 100644
--- a/addons/website_sale/models/res_config_settings.py
+++ b/addons/website_sale/models/res_config_settings.py
@@ -27,7 +27,6 @@ class ResConfigSettings(models.TransientModel):
group_delivery_invoice_address = fields.Boolean(string="Shipping Address", implied_group='sale.group_delivery_invoice_address', group='base.group_portal,base.group_user,base.group_public')
- module_website_sale_options = fields.Boolean("Optional Products")
module_website_sale_digital = fields.Boolean("Digital Content")
module_website_sale_wishlist = fields.Boolean("Wishlists")
module_website_sale_comparison = fields.Boolean("Product Comparison Tool")
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
index 476f0405e2b..3d7397a7079 100644
--- a/addons/website_sale/models/sale_order.py
+++ b/addons/website_sale/models/sale_order.py
@@ -72,7 +72,22 @@ class SaleOrder(models.Model):
domain = [('order_id', '=', self.id), ('product_id', '=', product_id)]
if line_id:
domain += [('id', '=', line_id)]
- return self.env['sale.order.line'].sudo().search(domain)
+
+ lines = self.env['sale.order.line'].sudo().search(domain)
+
+ if line_id:
+ return lines
+ linked_line_id = kwargs.get('linked_line_id', False)
+ optional_product_ids = set(kwargs.get('optional_product_ids', []))
+
+ lines = lines.filtered(lambda line: line.linked_line_id.id == linked_line_id)
+ if optional_product_ids:
+ # only match the lines with the same chosen optional products on the existing lines
+ lines = lines.filtered(lambda line: optional_product_ids == set(line.mapped('option_line_ids.product_id.id')))
+ else:
+ lines = lines.filtered(lambda line: not line.option_line_ids)
+
+ return lines
@api.multi
def _website_product_id_change(self, order_id, product_id, qty=0):
@@ -116,7 +131,7 @@ class SaleOrder(models.Model):
untracked_attributes = []
for k, v in attributes.items():
# attribute should be like 'attribute-48-1' where 48 is the product_id, 1 is the attribute_id and v is the attribute value
- attribute_value = self.env['product.attribute.value'].sudo().browse(int(v))
+ attribute_value = self.env['product.product.attribute.value'].sudo().browse(int(v))
if attribute_value and not attribute_value.attribute_id.create_variant:
untracked_attributes.append(attribute_value.name)
if untracked_attributes:
@@ -198,13 +213,34 @@ class SaleOrder(models.Model):
order_line.write(values)
- return {'line_id': order_line.id, 'quantity': quantity}
+ # link a product to the sales order
+ if kwargs.get('linked_line_id'):
+ linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id'])
+ order_line.write({
+ 'linked_line_id': linked_line.id,
+ 'name': order_line.name + "\n" + _("Option for:") + ' ' + linked_line.product_id.display_name,
+ })
+ linked_line.write({"name": linked_line.name + "\n" + _("Option:") + ' ' + order_line.product_id.display_name})
+
+ option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id)
+ for option_line_id in option_lines:
+ self._cart_update(option_line_id.product_id.id, option_line_id.id, add_qty, set_qty, **kwargs)
+
+ return {'line_id': order_line.id, 'quantity': quantity, 'option_ids': list(set(option_lines.ids))}
def _cart_accessories(self):
""" Suggest accessories based on 'Accessory Products' of products in cart """
for order in self:
accessory_products = order.website_order_line.mapped('product_id.accessory_product_ids').filtered(lambda product: product.website_published)
- accessory_products -= order.website_order_line.mapped('product_id')
+ products = order.website_order_line.mapped('product_id')
+ accessory_products -= products
+
+ for product in products:
+ for product_template in accessory_products.mapped('product_tmpl_id'):
+ template_accessory_products = accessory_products.filtered(lambda accessory_product: accessory_product.product_tmpl_id == product_template)
+ # remove from the accessories the ones that are not available for the selected product
+ accessory_products -= template_accessory_products - product_template.get_filtered_variants(product)
+
return random.sample(accessory_products, len(accessory_products))
@api.multi
@@ -240,6 +276,9 @@ class SaleOrderLine(models.Model):
name_short = fields.Char(compute="_compute_name_short")
+ linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade')
+ option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked')
+
@api.multi
@api.depends('product_id.display_name')
def _compute_name_short(self):
diff --git a/addons/website_sale/security/ir.model.access.csv b/addons/website_sale/security/ir.model.access.csv
index 9ff7391fc97..8c0f6569c8e 100644
--- a/addons/website_sale/security/ir.model.access.csv
+++ b/addons/website_sale/security/ir.model.access.csv
@@ -10,7 +10,8 @@ access_product_style,product.style.public,website_sale.model_product_style,,1,0,
access_product_supplierinfo,product.supplierinfo.public,product.model_product_supplierinfo,,1,0,0,0
access_product_attribute_public,product.attribute public,product.model_product_attribute,,1,0,0,0
access_product_attribute_value_public,product.attribute value public,product.model_product_attribute_value,,1,0,0,0
-access_product_attribute_price_public,product.attribute price public,product.model_product_attribute_price,,1,0,0,0
+access_product_product_attribute,product.product.attribute value public,product.model_product_product_attribute_value,,1,0,0,0
+access_product_attribute_filter_line,product.attribute filter line public,product.model_product_attribute_filter_line,,1,0,0,0
access_product_attribute_line_public,product.attribute line public,product.model_product_attribute_line,,1,0,0,0
access_fiscal_position_public,fiscal position public,account.model_account_fiscal_position,base.group_portal,1,0,0,0
access_payment_term,payment term public,account.model_account_payment_term,base.group_portal,1,0,0,0
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
index 37fe4372e94..64a353de923 100644
--- a/addons/website_sale/static/src/js/website_sale.js
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -124,6 +124,7 @@ odoo.define('website_sale.website_sale', function (require) {
'use strict';
var utils = require('web.utils');
+var ProductConfiguratorUtils = require('sale.product_configurator_utils');
var core = require('web.core');
var config = require('web.config');
var sAnimations = require('website.content.snippets.animation');
@@ -134,18 +135,18 @@ var _t = core._t;
sAnimations.registry.WebsiteSale = sAnimations.Class.extend({
selector: '.oe_website_sale',
read_events: {
- 'change input[name="add_qty"]': '_onChangeAddQuantity',
+ 'change form .js_product:first input[name="add_qty"]': '_onChangeAddQuantity',
'mouseup .js_publish': '_onMouseupPublish',
'touchend .js_publish': '_onMouseupPublish',
'change .oe_cart input.js_quantity[data-product-id]': '_onChangeCartQuantity',
'click .oe_cart a.js_add_suggested_products': '_onClickSuggestedProduct',
- 'click a.js_add_cart_json': '_onClickAddCartJSON',
+ 'click a.js_add_cart_json, button.js_add_cart_json': '_onClickAddCartJSON',
'click .a-submit': '_onClickSubmit',
'change form.js_attributes input, form.js_attributes select': '_onChangeAttribute',
'mouseup form.js_add_cart_json label': '_onMouseupAddCartLabel',
'touchend form.js_add_cart_json label': '_onMouseupAddCartLabel',
'change .css_attribute_color input': '_onChangeColorAttribute',
- 'change input.js_variant_change, select.js_variant_change, input.js_product_change, [data-attribute_value_ids]': '_onChangeVariant',
+ 'change form .js_main_product input.js_variant_change, form .js_main_product select.js_variant_change, form .js_main_product input.js_product_change, form .js_main_product [data-attribute_value_ids]': '_onChangeVariant',
'click .show_coupon': '_onClickShowCoupon',
'submit .o_website_sale_search': '_onSubmitSaleSearch',
'change select[name="country_id"]': '_onChangeCountry',
@@ -328,49 +329,6 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({
}
});
},
- /**
- * @private
- */
- _priceToStr: function (price) {
- var l10n = _t.database.parameters;
- var precision = 2;
-
- if ($(".decimal_precision").length) {
- precision = parseInt($(".decimal_precision").last().data('precision'));
- }
- var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.');
- formatted[0] = utils.insert_thousand_seps(formatted[0]);
- return formatted.join(l10n.decimal_point);
- },
- /**
- * @private
- */
- _updateProductImage: function ($productContainer, product_id) {
- var $img;
- if ($('#o-carousel-product').length) {
- $img = $productContainer.find('img.js_variant_img');
- $img.attr("src", "/web/image/product.product/" + product_id + "/image");
- $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id)
- .data('oe-model', 'product.product').data('oe-id', product_id);
-
- var $thumbnail = $productContainer.find('img.js_variant_img_small');
- if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed
- $thumbnail.attr("src", "/web/image/product.product/" + product_id + "/image/90x90");
- $('.carousel').carousel(0);
- }
- }
- else {
- $img = $productContainer.find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img');
- $img.attr("src", "/web/image/product.product/" + product_id + "/image");
- $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id)
- .data('oe-model', 'product.product').data('oe-id', product_id);
- }
- // reset zooming constructs
- $img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src'));
- if ($img.data('zoomOdoo') !== undefined) {
- $img.data('zoomOdoo').isReady = false;
- }
- },
//--------------------------------------------------------------------------
// Handlers
@@ -380,31 +338,8 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({
* @private
* @param {Event} ev
*/
- _onChangeAddQuantity: function (ev) {
- var productIDs = [];
- var $addVariant = $(ev.currentTarget).closest(".js_product").find(".js_add_cart_variants");
- var qty = $(ev.currentTarget).closest('form').find('input[name="add_qty"]').val();
- var attributeValueIDs = $addVariant.data("attribute_value_ids");
- _.each(attributeValueIDs, function (entry) {
- productIDs.push(entry[0]);
- });
-
- if ($("#product_detail").length) {
- // display the reduction from the pricelist in function of the quantity
- this._rpc({
- route: '/shop/get_unit_price',
- params: {
- product_ids: productIDs,
- add_qty: parseInt(qty),
- },
- }).then(function (data) {
- var current = attributeValueIDs;
- for (var j = 0 ; j < current.length ; j++) {
- current[j][2] = data[current[j][0]];
- }
- $addVariant.trigger('change');
- });
- }
+ _onChangeAddQuantity: function (ev, isWebsite, pricelist, no_stock_check) {
+ ProductConfiguratorUtils.onChangeAddQuantity(ev, true, pricelist, no_stock_check);
},
/**
* @private
@@ -444,27 +379,17 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({
* @private
* @param {Event} ev
*/
- _onClickAddCartJSON: function (ev) { // hack to add and remove from cart with json
- ev.preventDefault();
- var $link = $(ev.currentTarget);
- var $input = $link.closest('.input-group').find("input");
- var product_id = +$input.closest('*:has(input[name="product_id"])').find('input[name="product_id"]').val();
- var min = parseFloat($input.data("min") || 0);
- var max = parseFloat($input.data("max") || Infinity);
- var quantity = ($link.has(".fa-minus").length ? -1 : 1) + parseFloat($input.val() || 0, 10);
- var new_qty = quantity > min ? (quantity < max ? quantity : max) : min;
- // if they are more of one input for this product (eg: option modal)
- $('input[name="'+$input.attr("name")+'"]').add($input).filter(function () {
- var $prod = $(this).closest('*:has(input[name="product_id"])');
- return !$prod.length || +$prod.find('input[name="product_id"]').val() === product_id;
- }).val(new_qty).change();
- return false;
+ _onClickAddCartJSON: function (ev) {
+ ProductConfiguratorUtils.onClickAddCartJSON(ev);
},
/**
* @private
* @param {Event} ev
*/
- _onClickSubmit: function (ev) {
+ _onClickSubmit: function (ev, forceSubmit) {
+ if ($(ev.currentTarget).is('#add_to_cart, #products_grid .a-submit') && !forceSubmit) {
+ return;
+ }
var $aSubmit = $(ev.currentTarget);
if (!ev.isDefaultPrevented() && !$aSubmit.is(".disabled")) {
ev.preventDefault();
@@ -522,70 +447,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({
* @param {Event} ev
*/
_onChangeVariant: function (ev) {
- var $parent = $(ev.target).closest('.js_product');
- var $ul = $parent.find('.js_add_cart_variants');
- var $product_id = $parent.find('.product_id').first();
- var $price = $parent.find(".oe_price:first .oe_currency_value");
- var $default_price = $parent.find(".oe_default_price:first .oe_currency_value");
- var $optional_price = $parent.find(".oe_optional:first .oe_currency_value");
- var variant_ids = $ul.data("attribute_value_ids");
- var values = [];
- var unchanged_values = $parent.find('div.oe_unchanged_value_ids').data('unchanged_value_ids') || [];
-
- _.each($parent.find('input.js_variant_change:checked, select.js_variant_change'), function (el) {
- values.push(+$(el).val());
- });
- values = values.concat(unchanged_values);
- var list_variant_id = parseInt($parent.find('input.js_product_change:checked').val());
-
- $parent.find("label").removeClass("text-muted css_not_available");
-
- var product_id = false;
- for (var k in variant_ids) {
- if (_.isEmpty(_.difference(variant_ids[k][1], values)) || variant_ids[k][0] === list_variant_id) {
- $price.html(this._priceToStr(variant_ids[k][2]));
- $default_price.html(this._priceToStr(variant_ids[k][3]));
- if (variant_ids[k][3]-variant_ids[k][2]>0.01) {
- $default_price.closest('.oe_website_sale').addClass("discount");
- $optional_price.closest('.oe_optional').show().css('text-decoration', 'line-through');
- $default_price.parent().removeClass('d-none');
- } else {
- $optional_price.closest('.oe_optional').hide();
- $default_price.parent().addClass('d-none');
- }
- product_id = variant_ids[k][0];
- this._updateProductImage($(ev.currentTarget).closest('tr.js_product, .oe_website_sale'), product_id);
- break;
- }
- }
-
- _.each($parent.find("input.js_variant_change:radio, select.js_variant_change"), function (elem) {
- var $input = $(elem);
- var id = +$input.val();
- var values = [id];
-
- _.each($parent.find("ul:not(:has(input.js_variant_change[value='" + id + "'])) input.js_variant_change:checked, select.js_variant_change"), function (elem) {
- values.push(+$(elem).val());
- });
-
- for (var k in variant_ids) {
- if (!_.difference(values, variant_ids[k][1]).length) {
- return;
- }
- }
- $input.closest("label").addClass("css_not_available");
- $input.find("option[value='" + id + "']").addClass("css_not_available");
- });
-
- if (product_id) {
- $parent.removeClass("css_not_available");
- $product_id.val(product_id);
- $parent.find("#add_to_cart").removeClass("disabled");
- } else {
- $parent.addClass("css_not_available");
- $product_id.val(0);
- $parent.find("#add_to_cart").addClass("disabled");
- }
+ ProductConfiguratorUtils.onChangeVariant(ev, false);
},
/**
* @private
diff --git a/addons/website_sale/static/src/js/website_sale_options.js b/addons/website_sale/static/src/js/website_sale_options.js
new file mode 100644
index 00000000000..cb9e93c204b
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_options.js
@@ -0,0 +1,88 @@
+odoo.define('website_sale_options.website_sale', function (require) {
+ 'use strict';
+
+ var OptionalProductsModal = require('sale.OptionalProductsModal');
+ var weContext = require('web_editor.context');
+ var sAnimations = require('website.content.snippets.animation');
+ require('website_sale.website_sale');
+
+ var product_name_map = {};
+ var optional_products_map = {};
+
+ sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend({
+ selector: '.oe_website_sale',
+ read_events: {
+ 'click #add_to_cart, #products_grid .product_price .a-submit': '_onClickAdd',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this._handleAdd = _.debounce(this._handleAdd.bind(this), 200, true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _handleAdd: function ($form) {
+ var self = this;
+ var quantity = parseFloat($form.find('input[name="add_qty"]').val() || 1);
+ var product_id = parseInt($form.find('input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked').first().val(),10);
+
+ var modal = new OptionalProductsModal(quantity, product_id, $form, null, true);
+
+ modal.on('options_empty', null, function () {
+ $form.trigger('submit', [true]);
+ });
+
+ modal.on('back', null, function () {
+ self._onModalSubmit($form);
+ });
+
+ modal.on('confirm', null, function () {
+ self._onModalSubmit($form, true);
+ });
+
+ modal.on('modal_ready', null, function ($modal) {
+ $modal.on('hidden.bs.modal', function () {
+ $form.removeClass('css_options'); // possibly reactivate opacity (see above)
+ $(this).remove();
+ });
+ });
+
+ modal.appendTo($form);
+ },
+
+ _onModalSubmit: function($form, go_to_shop){
+ $form.ajaxSubmit({
+ url: '/shop/cart/update_option',
+ data: {lang: weContext.get().lang},
+ success: function (quantity) {
+ if (go_to_shop) {
+ window.location.pathname = window.location.pathname.replace(/shop([\/?].*)?$/, "shop/cart");
+ }
+ var $q = $(".my_cart_quantity");
+ $q.parent().parent().removeClass("d-none", !quantity);
+ $q.html(quantity).hide().fadeIn(600);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onClickAdd: function (ev) {
+ ev.preventDefault();
+ this._handleAdd($(ev.currentTarget).closest('form'));
+ },
+ });
+ });
+ \ No newline at end of file
diff --git a/addons/website_sale/static/src/js/website_sale_tour_buy.js b/addons/website_sale/static/src/js/website_sale_tour_buy.js
index c2ffef9b81e..bf674dc7a47 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_buy.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_buy.js
@@ -39,6 +39,10 @@ tour.register('shop_buy_product', {
trigger: '#product_detail form[action^="/shop/cart/update"] .btn-primary',
},
{
+ content: "click in modal on 'Proceed to checkout' button",
+ trigger: 'a:contains("Proceed to Checkout")',
+ },
+ {
content: "add suggested",
extra_trigger: '#wrap:not(:has(#cart_products:contains("[A8767] Storage Box")))',
trigger: '.oe_cart:has(tr:contains("Steel")) a:contains("Add to Cart")',
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
new file mode 100644
index 00000000000..11e7ed573b2
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
@@ -0,0 +1,216 @@
+odoo.define('website_sale.tour_shop_customize', function (require) {
+ 'use strict';
+
+ var tour = require("web_tour.tour");
+ var base = require("web_editor.base");
+
+ tour.register('shop_customize', {
+ test: true,
+ url: '/shop',
+ wait_for: base.ready()
+ },
+ [
+ {
+ content: "open customize menu",
+ trigger: '#customize-menu > a',
+ },
+ {
+ content: "click on 'Product Attribute's Filters'",
+ trigger: "#customize-menu a:contains(Product Attribute's Filters)",
+ },
+ {
+ content: "select product attribute memory Steel",
+ extra_trigger: 'body:not(:has(#customize-menu:visible .dropdown-menu:visible))',
+ trigger: 'form.js_attributes label:contains(Steel) input:not(:checked)',
+ },
+ {
+ content: "check the selection",
+ trigger: 'form.js_attributes label:contains(Steel) input:checked',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "select product",
+ extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(2)))',
+ trigger: '.oe_product_cart a:contains("Customizable Desk")',
+ },
+ {
+ content: "open customize menu",
+ trigger: '#customize-menu',
+ extra_trigger: "#product_detail",
+ run: function() {
+ // ENABLE VARIANT GROUP
+ $('body').addClass('notReady');
+ var ajax = odoo.__DEBUG__.services['web.ajax'];
+ var get_group_payload = {
+ model: 'ir.model.data',
+ method: 'xmlid_to_res_id',
+ args: ['product.group_product_variant', false],
+ kwargs: {}
+ };
+ ajax.jsonpRpc('/web/dataset/call_kw', 'call', get_group_payload).then(function(group_id) {
+ ajax.jsonpRpc('/web/dataset/call_kw', 'call', {
+ model: 'res.groups',
+ method: 'write',
+ args: [group_id, {'users': [[4, 1]]}],
+ kwargs: {}
+ }).then(function() {
+ location.reload(true);
+ });
+ });
+ },
+ },
+ {
+ content: "open customize menu",
+ trigger: '#customize-menu > a',
+ extra_trigger: 'body:not(.notReady)',
+ },
+ {
+ content: "check page loaded after enable variant group",
+ trigger: '#customize-menu a:contains(List View of Variants)',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "click on 'List View of Variants'",
+ trigger: "#customize-menu a:contains(List View of Variants)",
+ },
+ {
+ content: "check page loaded after list of variant customization enabled",
+ trigger: '.js_product_change',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "check price is 750 and set quantity to 2",
+ trigger: ".js_product:first input.quantity:propValue(1)",
+ extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
+ run: "text 2",
+ },
+ {
+ content: "verify pricelist based on quantity has effect",
+ trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "check pricelit has been applied and switch to Aluminium variant",
+ trigger: ".js_product label:contains('Aluminium')",
+ },
+ {
+ content: "verify that price has changed when changing variant",
+ trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "open customize menu",
+ trigger: '#customize-menu > a',
+ },
+ {
+ content: "remove 'List View of Variants'",
+ trigger: "#customize-menu a:contains(List View of Variants):has(input:checked)",
+ },
+ {
+ content: "check page loaded after list of variant customization disabled",
+ trigger: ".js_product:not(:has(.js_product_change))",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "check price is 750 and set quantity to 2",
+ trigger: ".js_product:first input.quantity:propValue(1)",
+ extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
+ run: "text 2",
+ },
+ {
+ content: "verify pricelist based on quantity has effect",
+ trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "switch to Aluminium variant",
+ trigger: ".js_product label:contains('Aluminium')",
+ },
+ {
+ content: "verify that price has changed when changing variant",
+ trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "switch back to Steel variant",
+ trigger: ".js_product label:contains('Steel')",
+ },
+ {
+ content: "click on 'Add to Cart' button",
+ trigger: "a:contains(Add to Cart)",
+ },
+ {
+ content: "price is lowered by pricelist and not multiplied by quantity",
+ trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(600.00)",
+ extra_trigger: "#product_confirmation input.quantity:propValue(2)",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "set quantity to 1",
+ trigger: "#product_confirmation .js_add_cart_json .fa-minus",
+ },
+ {
+ content: "check that product page has been updated",
+ trigger: ".js_product:first input.quantity:propValue(1)",
+ extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "check that add to cart modal has been updated",
+ trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(750.00)",
+ extra_trigger: "#product_confirmation input.quantity:propValue(1)",
+ run: function () {}, // it's a check
+ },
+ {
+ content: "add an optional Warranty",
+ trigger: ".js_product:contains(Warranty) a:contains(Add to Cart)",
+ },
+ {
+ content: "click in modal on 'Proceed to checkout' button",
+ extra_trigger: 'body:has(.js_product:contains(Warranty) a:contains(Add to Cart):hidden)',
+ trigger: '.modal-footer a:contains("Proceed to Checkout")',
+ },
+ {
+ content: "check quantity",
+ trigger: '.my_cart_quantity:containsExact(2),.o_extra_menu_items .fa-plus',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "check optional product",
+ trigger: '.optional_product',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "remove large cabinet from cart",
+ trigger: '#cart_products a.js_add_cart_json:first',
+ },
+ {
+ content: "check optional product is removed",
+ trigger: '#wrap:not(:has(.optional_product))',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "click on shop",
+ trigger: "a:contains(Continue Shopping)",
+ extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))',
+ },
+ {
+ content: "open customize menu bis",
+ extra_trigger: '#products_grid_before .js_attributes',
+ trigger: '#customize-menu > a',
+ },
+ {
+ content: "remove 'Product Attribute's Filters'",
+ trigger: "#customize-menu a:contains(Product Attribute's Filters):has(input:checked)",
+ },
+ {
+ content: "finish",
+ extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))',
+ trigger: '#wrap:not(:has(li:has(.my_cart_quantity):visible))',
+ run: function () {}, // it's a check
+ },
+ ]
+ );
+
+ });
+ \ No newline at end of file
diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss
index bd928b6f974..f2eed17f3b6 100644
--- a/addons/website_sale/static/src/scss/website_sale.scss
+++ b/addons/website_sale/static/src/scss/website_sale.scss
@@ -29,66 +29,10 @@
margin-left: 1.5em;
}
- .td-product_name,
h1[itemprop="name"],
.oe_product_cart form h5 {
word-wrap: break-word;
}
-
- .td-product_name {
- min-width: 140px;
- }
-
- .td-img {
- width: 100px;
- }
-
- .td-qty {
- width: 130px;
- a.input-group-addon {
- background-color: transparent;
- border: 0px;
- }
- }
- .td-action {
- width: 30px;
- }
-
- .td-price,
- .td-price-total {
- width: 100px;
- }
-
- @include media-breakpoint-down(sm) {
- .td-img,
- .td-price-total {
- display: none;
- }
-
- .td-qty {
- width: 60px;
- }
-
- .td-price {
- width: 80px;
- }
- }
-
- @media (max-width: 476px) {
- .td-qty {
- width: 60px;
- }
-
- #modal_optional_products table thead,
- .oe_cart table thead {
- display: none;
- }
-
- #modal_optional_products table td.td-img,
- .oe_cart table td.td-img {
- display: none;
- }
- }
}
/* ---- Default Styles ---- */
@@ -483,67 +427,6 @@ div#payment_method {
}
/* product detail */
-.css_attribute_color {
- display: inline-block;
- border: 1px solid #999999;
- text-align: center;
-
- input {
- margin: 8px;
- height: 13px;
- opacity: 0;
- }
-
- &.active {
- border: 3px ridge #66ee66;
- }
-
- &.active input {
- margin: 6px;
- }
-}
-
-.css_not_available_msg {
- display: none;
-}
-
-.css_not_available.js_product {
- > *:nth-child(4) > * {
- display: none;
- }
-
- .css_quantity,
- .product_price {
- display: none;
- }
-
- .css_not_available_msg {
- display: block;
- }
-}
-
-.css_quantity {
- max-width: 125px;
-
- input[name="add_qty"] {
- text-align: center;
- }
-}
-
-option.css_not_available {
- color: #ccc;
-}
-
-label.css_not_available {
- opacity: 0.6;
-}
-
-label.css_attribute_color.css_not_available {
- opacity: 1;
- background-image: url("/website_sale/static/src/img/redcross.png");
- background-size: cover;
-}
-
.product_detail_img {
margin-left: auto;
margin-right: auto;
@@ -551,14 +434,6 @@ label.css_attribute_color.css_not_available {
max-height: 500px;
}
-.oe_default_price {
- display: none;
-}
-
-.discount .oe_default_price {
- display: block;
-}
-
.oe_website_sale input.js_quantity {
min-width: 48px;
text-align: center;
diff --git a/addons/website_sale_options/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale_options.scss
index 7c129d35b4f..200c9eadd6a 100644
--- a/addons/website_sale_options/static/src/scss/website_sale.scss
+++ b/addons/website_sale/static/src/scss/website_sale_options.scss
@@ -16,9 +16,4 @@ div#modal_optional_products table tr td {
div#modal_optional_products .td-qty {
display: none;
}
-}
-
-.oe_striked_price {
- text-decoration: line-through;
- white-space: nowrap;
-}
+} \ No newline at end of file
diff --git a/addons/website_sale_options/tests/test_customize.py b/addons/website_sale/tests/test_customize.py
index 5a397dbba54..e387c068a0b 100644
--- a/addons/website_sale_options/tests/test_customize.py
+++ b/addons/website_sale/tests/test_customize.py
@@ -6,8 +6,4 @@ import odoo.tests
class TestUi(odoo.tests.HttpCase):
def test_01_admin_shop_customize_tour(self):
- self.env.ref('product.group_product_variant').write({
- 'users': [(4, self.env.ref('base.user_admin').id)]
- })
-
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_customize')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_customize.ready", login="admin")
diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml
index 13c77ff81c0..9ebf0340b9b 100644
--- a/addons/website_sale/views/product_views.xml
+++ b/addons/website_sale/views/product_views.xml
@@ -28,35 +28,6 @@
</field>
</record>
- <record id="product_attribute_line_form" model="ir.ui.view">
- <field name="name">product.attribute.line.form.inherit</field>
- <field name="model">product.attribute.line</field>
- <field name="inherit_id" ref="product.product_attribute_line_form"/>
- <field name="priority" eval="8"/>
- <field name="arch" type="xml">
- <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after">
- <field name="html_color"/>
- </xpath>
- </field>
- </record>
-
- <record id="product_attribute_view_form" model="ir.ui.view">
- <field name="name">product.attribute.form.inherit</field>
- <field name="model">product.attribute</field>
- <field name="inherit_id" ref="product.product_attribute_view_form"/>
- <field name="priority" eval="8"/>
- <field name="arch" type="xml">
- <xpath expr="//group[@name='main_fields']" position="after">
- <group>
- <field name="type"/>
- </group>
- </xpath>
- <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after">
- <field name="html_color"/>
- </xpath>
- </field>
- </record>
-
<record model="ir.ui.view" id="product_pricelist_view">
<field name="name">product.pricelist.website.form</field>
<field name="model">product.pricelist</field>
@@ -154,39 +125,6 @@
</field>
</record>
- <!-- Product attribute -->
-
- <record id="product_attribute_value_view_tree_inherit_website_sale" model="ir.ui.view">
- <field name="name">product.attribute.value.view.tree.inherit.website.sale</field>
- <field name="model">product.attribute.value</field>
- <field name="inherit_id" ref="product.product_attribute_value_view_tree"/>
- <field name="arch" type="xml">
- <field name="name" position="after">
- <field name="html_color"/>
- </field>
- </field>
- </record>
- <record id="variants_tree_view" model="ir.ui.view">
- <field name="name">variants.template.tree</field>
- <field name="model">product.attribute.value</field>
- <field name="inherit_id" ref="product.variants_tree_view"/>
- <field name="arch" type="xml">
- <field name="name" position="after">
- <field name="html_color"/>
- </field>
- </field>
- </record>
- <record id="attribute_tree_view" model="ir.ui.view">
- <field name="name">product.attribute.tree.type</field>
- <field name="model">product.attribute</field>
- <field name="inherit_id" ref="product.attribute_tree_view"></field>
- <field name="arch" type="xml">
- <field name="name" position="after">
- <field name="type"/>
- </field>
- </field>
- </record>
-
<!-- Product Public Categories -->
<record id="product_public_category_form_view" model="ir.ui.view">
<field name="name">product.public.category.form</field>
diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml
index f0dda9eef90..04410eb05d0 100644
--- a/addons/website_sale/views/res_config_settings_views.xml
+++ b/addons/website_sale/views/res_config_settings_views.xml
@@ -59,17 +59,6 @@
</div>
</div>
</div>
- <div class="col-12 col-lg-6 o_setting_box" id="sale_option_setting" title="Select optional products from the configuration form of your main products (in Sales tab).">
- <div class="o_setting_left_pane">
- <field name="module_website_sale_options"/>
- </div>
- <div class="o_setting_right_pane">
- <label for="module_website_sale_options"/>
- <div class="text-muted">
- Suggest optional products when adding to cart
- </div>
- </div>
- </div>
<div class="col-12 col-lg-6 o_setting_box" id="product_availability_setting">
<div class="o_setting_left_pane">
<field name="module_website_sale_stock"/>
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 83fb7f4fef9..68052b253d6 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -14,14 +14,21 @@
<link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale.scss" />
<link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_mail.scss" />
<link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_frontend.scss"/>
+ <link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/sale_portal.scss"/>
+ <link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/product_configurator.scss"/>
+ <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_options.scss"/>
</xpath>
<xpath expr="script[last()]" position="after">
+ <script type="text/javascript" src="/sale/static/src/js/product_configurator_utils.js"></script>
+ <script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_utils.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_payment.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_validate.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_buy.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tracking.js"></script>
+ <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_customize.js"></script>
+ <script type="text/javascript" src="/website_sale/static/src/js/website_sale_options.js"></script>
</xpath>
</template>
@@ -149,6 +156,12 @@
</a>
</t>
</xpath>
+ <xpath expr="//div[hasclass('product_price')]/t[1]" position="attributes">
+ <attribute name="t-if">len(product.product_variant_ids) == 1 and not len(product.optional_product_ids) &gt;= 1</attribute>
+ </xpath>
+ <xpath expr="//div[hasclass('product_price')]/t[2]" position="attributes">
+ <attribute name="t-if">len(product.product_variant_ids) &gt; 1 or len(product.optional_product_ids) &gt;= 1</attribute>
+ </xpath>
</template>
<template id="pricelist_list" name="Pricelists Dropdown">
@@ -470,10 +483,10 @@
<span itemprop="url" style="display:none;" t-esc="'%sshop/product/%s' % (request.httprequest.url_root, slug(product))"/>
<form action="/shop/cart/update" method="POST">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
- <div class="js_product" t-if="product.product_variant_ids">
+ <div class="js_product js_main_product" t-if="product.product_variant_ids">
<t t-placeholder="select">
<input type="hidden" class="product_id" name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'" />
- <t t-call="website_sale.variants">
+ <t t-call="sale.variants">
<t t-set="ul_class" t-value="'flex-column'" />
</t>
</t>
@@ -602,13 +615,13 @@
"display_currency": product.currency_id,
}'/>
</h4>
- <h4 class="d-none oe_not_available bg-warning">Product not available</h4>
+ <h4 class="d-none oe_not_available bg-warning">Option not available</h4>
</div>
</template>
<template id="product_variants" inherit_id="website_sale.product" active="False" customize_show="True" name="List View of Variants">
<xpath expr="//t[@t-placeholder='select']" position="replace">
- <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/>
+ <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product, reference_product, pricelist)"/>
<ul class="d-none js_add_cart_variants" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"/>
<input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/>
<t t-if="len(product.product_variant_ids) &gt; 1">
@@ -626,75 +639,6 @@
</xpath>
</template>
- <template id="variants">
- <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/>
- <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)">
- <t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id">
- <li t-if="len(variant_id.value_ids) > 1">
-
- <strong t-field="variant_id.attribute_id.name"/>
-
- <t t-if="variant_id.attribute_id.type == 'select'">
- <select t-attf-class="form-control #{'js_variant_change' if variant_id.attribute_id.create_variant else ''}" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
- <t t-foreach="variant_id.value_ids" t-as="value_id">
- <option t-att-value="value_id.id">
- <span t-field="value_id.name"/>
- <span t-if="value_id.price_extra">
- <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" style="white-space: nowrap;" t-options='{
- "widget": "monetary",
- "from_currency": product.currency_id,
- "display_currency": website.currency_id
- }'/>
- </span>
- </option>
- </t>
- </select>
- </t>
-
- <t t-if="variant_id.attribute_id.type == 'radio'">
- <ul class="list-unstyled">
- <t t-set="inc" t-value="0"/>
- <t t-foreach="variant_id.value_ids" t-as="value_id">
- <li class="form-group js_attribute_value" style="margin: 0;">
- <label class="col-form-label" style="margin: 0 20px;">
- <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" t-att-checked="'checked' if not inc else None" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" t-att-value="value_id.id" style="vertical-align: top; margin-right: 10px;"/>
- <span t-field="value_id.name"/>
- <span class="badge badge-pill" t-if="value_id.price_extra">
- <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" style="white-space: nowrap;" t-options='{
- "widget": "monetary",
- "from_currency": product.currency_id,
- "display_currency": website.currency_id
- }'/>
- </span>
- </label>
- </li>
- <t t-set="inc" t-value="inc+1"/>
- </t>
- </ul>
- </t>
-
- <t t-if="variant_id.attribute_id.type == 'color'">
- <ul class="list-inline">
- <t t-set="inc" t-value="0"/>
- <li t-foreach="variant_id.value_ids" t-as="value_id" class="list-inline-item">
- <label t-attf-style="background-color:#{value_id.html_color or value_id.name}"
- t-attf-class="css_attribute_color #{'active' if not inc else ''}">
- <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None"
- t-att-checked="'checked' if not inc else None"
- 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"/>
- </label>
- <t t-set="inc" t-value="inc+1"/>
- </li>
- </ul>
- </t>
-
- </li>
- </t>
- </ul>
- </template>
-
<template id="wizard_checkout" name="Wizard Checkout">
<t t-set="website_sale_order" t-value="website.sale_get_order()"/>
@@ -840,7 +784,7 @@
</thead>
<tbody>
<t t-foreach="website_sale_order.website_order_line" t-as="line">
- <tr>
+ <tr t-att-class="'optional_product info' if line.linked_line_id else None">
<td colspan="2" t-if="not line.product_id.product_tmpl_id" class='td-img'></td>
<td align="center" t-if="line.product_id.product_tmpl_id" class='td-img'>
<span t-field="line.product_id.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" />
@@ -856,7 +800,7 @@
</t>
</td>
<td class="text-center td-qty">
- <div class="css_quantity input-group oe_website_spinner">
+ <div t-if="not line.linked_line_id" class="css_quantity input-group oe_website_spinner">
<div class="input-group-prepend">
<a t-attf-href="#" class="btn btn-link js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
@@ -869,6 +813,9 @@
</a>
</div>
</div>
+ <t t-if="line.linked_line_id">
+ <span class="js_quantity text-muted" t-att-data-line-id="line.id" t-att-data-product-id="line.product_id.id" t-esc="int(line.product_uom_qty)"/>
+ </t>
</td>
<td class="text-center td-price" name="price">
<t t-if="(compute_currency(line.product_id.lst_price) - line.price_reduce ) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
diff --git a/addons/website_sale_comparison/views/website_sale_comparison_view.xml b/addons/website_sale_comparison/views/website_sale_comparison_view.xml
index a49c73e61c7..0237d0cfbab 100644
--- a/addons/website_sale_comparison/views/website_sale_comparison_view.xml
+++ b/addons/website_sale_comparison/views/website_sale_comparison_view.xml
@@ -38,7 +38,7 @@
<record id="product_attribute_view_form" model="ir.ui.view">
<field name="name">product.attribute.form.inherit</field>
<field name="model">product.attribute</field>
- <field name="inherit_id" ref="website_sale.product_attribute_view_form"/>
+ <field name="inherit_id" ref="product.product_attribute_view_form"/>
<field name="priority" eval="8"/>
<field name="arch" type="xml">
<field name='name' position='after'>
diff --git a/addons/website_sale_options/__init__.py b/addons/website_sale_options/__init__.py
deleted file mode 100644
index 7d34c7c054a..00000000000
--- a/addons/website_sale_options/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from . import controllers
-from . import models
diff --git a/addons/website_sale_options/__manifest__.py b/addons/website_sale_options/__manifest__.py
deleted file mode 100644
index bb7c76099e0..00000000000
--- a/addons/website_sale_options/__manifest__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-{
- 'name': 'Optional Products',
- 'category': 'Website',
- 'summary': 'Suggest optional products when adding to cart',
- 'version': '1.0',
- 'description': """
-Suggest optional products when shoppers add products to their cart (e.g. for computers: warranty, OS software, extra components).
-Optional products are defined in the product form.
- """,
- 'depends': ['website_sale'],
- 'data': [
- 'views/product_views.xml',
- 'views/website_sale_options_templates.xml',
- ],
- 'demo': [
- 'data/product_demo.xml',
- ],
- 'qweb': ['static/src/xml/*.xml'],
- 'installable': True,
-}
diff --git a/addons/website_sale_options/controllers/__init__.py b/addons/website_sale_options/controllers/__init__.py
deleted file mode 100644
index 5d4b25db9c0..00000000000
--- a/addons/website_sale_options/controllers/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from . import main
diff --git a/addons/website_sale_options/controllers/main.py b/addons/website_sale_options/controllers/main.py
deleted file mode 100644
index c4d5f761258..00000000000
--- a/addons/website_sale_options/controllers/main.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo import fields, http
-from odoo.http import request
-from odoo.addons.website_sale.controllers.main import WebsiteSale
-
-
-class WebsiteSaleOptions(WebsiteSale):
-
- @http.route(['/shop/product/<model("product.template"):product>'], type='http', auth="public", website=True)
- def product(self, product, category='', search='', **kwargs):
- r = super(WebsiteSaleOptions, self).product(product, category, search, **kwargs)
- r.qcontext['optional_product_ids'] = [p.with_context({'active_id': p.id}) for p in product.optional_product_ids]
- return r
-
- @http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False)
- def cart_options_update_json(self, product_id, add_qty=1, set_qty=0, goto_shop=None, lang=None, **kw):
- if lang:
- request.website = request.website.with_context(lang=lang)
-
- order = request.website.sale_get_order(force_create=1)
- product = request.env['product.product'].browse(int(product_id))
-
- option_ids = product.optional_product_ids.mapped('product_variant_ids').ids
- optional_product_ids = []
- for k, v in kw.items():
- if "optional-product-" in k and int(kw.get(k.replace("product", "add"))) and int(v) in option_ids:
- optional_product_ids.append(int(v))
-
- attributes = self._filter_attributes(**kw)
-
- value = {}
- if add_qty or set_qty:
- value = order._cart_update(
- product_id=int(product_id),
- add_qty=add_qty,
- set_qty=set_qty,
- attributes=attributes,
- optional_product_ids=optional_product_ids
- )
-
- # options have all time the same quantity
- for option_id in optional_product_ids:
- order._cart_update(
- product_id=option_id,
- set_qty=value.get('quantity'),
- attributes=attributes,
- linked_line_id=value.get('line_id')
- )
-
- return str(order.cart_quantity)
-
- @http.route(['/shop/modal'], type='json', auth="public", methods=['POST'], website=True)
- def modal(self, product_id, **kw):
- pricelist = request.website.get_current_pricelist()
- product_context = dict(request.context)
- quantity = kw['kwargs']['context']['quantity']
- if not product_context.get('pricelist'):
- product_context['pricelist'] = pricelist.id
- # fetch quantity from custom context
- product_context.update(kw.get('kwargs', {}).get('context', {}))
-
- from_currency = request.env.user.company_id.currency_id
- to_currency = pricelist.currency_id
- company = request.env['res.company'].browse(request.env.context.get('company_id')) or request.env['res.users']._get_company()
- date = request.env.context.get('date') or fields.Date.today()
- compute_currency = lambda price: from_currency._convert(
- price, to_currency, company, date)
- product = request.env['product.product'].with_context(product_context).browse(int(product_id))
-
- main_product_attr_ids = self.get_attribute_value_ids(product)
- for variant in main_product_attr_ids:
- if variant[0] == product.id:
- # We indeed need a list of lists (even with only 1 element)
- main_product_attr_ids = [variant]
- break
-
- return request.env['ir.ui.view'].render_template("website_sale_options.modal", {
- 'product': product,
- 'quantity': quantity,
- 'compute_currency': compute_currency,
- 'get_attribute_value_ids': self.get_attribute_value_ids,
- 'main_product_attr_ids': main_product_attr_ids,
- })
diff --git a/addons/website_sale_options/data/product_demo.xml b/addons/website_sale_options/data/product_demo.xml
deleted file mode 100644
index 75820c74454..00000000000
--- a/addons/website_sale_options/data/product_demo.xml
+++ /dev/null
@@ -1,38 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <data noupdate="1">
- <record id="product_product_1" model="product.product">
- <field name="name">Warranty</field>
- <field name="default_code">SERV_125889</field>
- <field name="list_price">20.0</field>
- <field name="website_sequence">100</field>
- <field name="website_published" eval="True"/>
- <field name="type">service</field>
- <field name="uom_id" ref="uom.product_uom_unit"/>
- <field name="uom_po_id" ref="uom.product_uom_unit"/>
- <field name="description_sale">Warranty, issued to the purchaser of an article by its manufacturer, promising to repair or replace it if necessary within a specified period of time.</field>
- <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_5')])]"/>
- <field name="categ_id" ref="product.product_category_3"/>
- </record>
- <record id="product_attribute_line_1" model="product.attribute.line">
- <field name="product_tmpl_id" ref="product_product_1_product_template"/>
- <field name="attribute_id" ref="product.product_attribute_3"/>
- <field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_5'), ref('product.product_attribute_value_6')])]"/>
- </record>
- <record id="product_product_1_product_template" model="product.template">
- <field name="attribute_line_ids" eval="[(6,0,[ref('product_attribute_line_1')])]"/>
- <field name="public_categ_ids" eval="[(6,0,[ref('website_sale.services')])]"/>
- </record>
- <record id="product.product_product_4_product_template" model="product.template">
- <field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/>
- </record>
- <record id="product_attribute_price_1" model="product.attribute.price">
- <field name="product_tmpl_id" ref="product_product_1_product_template"/>
- <field name="value_id" ref="product.product_attribute_value_6"/>
- <field name="price_extra">18.00</field>
- </record>
-
- <!-- -->
-
- </data>
-</odoo>
diff --git a/addons/website_sale_options/models/__init__.py b/addons/website_sale_options/models/__init__.py
deleted file mode 100644
index ff797314513..00000000000
--- a/addons/website_sale_options/models/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from . import product
-from . import sale_order
diff --git a/addons/website_sale_options/models/sale_order.py b/addons/website_sale_options/models/sale_order.py
deleted file mode 100644
index c5d105fbeb7..00000000000
--- a/addons/website_sale_options/models/sale_order.py
+++ /dev/null
@@ -1,55 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo import api, fields, models, _
-
-
-class SaleOrderLine(models.Model):
- _inherit = "sale.order.line"
-
- linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade')
- option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked')
-
-
-class SaleOrder(models.Model):
- _inherit = "sale.order"
-
- @api.multi
- def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs):
- self.ensure_one()
- lines = super(SaleOrder, self)._cart_find_product_line(product_id, line_id)
- if line_id:
- return lines
- linked_line_id = kwargs.get('linked_line_id', False)
- optional_product_ids = set(kwargs.get('optional_product_ids', []))
-
- lines = lines.filtered(lambda line: line.linked_line_id.id == linked_line_id)
- if optional_product_ids:
- # only match the lines with the same chosen optional products on the existing lines
- lines = lines.filtered(lambda line: optional_product_ids == set(line.mapped('option_line_ids.product_id.id')))
- else:
- lines = lines.filtered(lambda line: not line.option_line_ids)
- return lines
-
- @api.multi
- def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs):
- self.ensure_one()
- """ Add or set product quantity, add_qty can be negative """
- value = super(SaleOrder, self)._cart_update(product_id, line_id, add_qty, set_qty, **kwargs)
- SaleOrderLineSudo = self.env['sale.order.line'].sudo()
- line = SaleOrderLineSudo.browse(value.get('line_id'))
- # link a product to the sales order
- if kwargs.get('linked_line_id'):
- linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id'])
- line.write({
- 'linked_line_id': linked_line.id,
- 'name': line.name + "\n" + _("Option for:") + ' ' + linked_line.product_id.display_name,
- })
- linked_line.write({"name": linked_line.name + "\n" + _("Option:") + ' ' + line.product_id.display_name})
-
- option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == line.id)
- for option_line_id in option_lines:
- super(SaleOrder, self)._cart_update(option_line_id.product_id.id, option_line_id.id, add_qty, set_qty, **kwargs)
-
- value['option_ids'] = list(set(option_lines.ids))
- return value
diff --git a/addons/website_sale_options/static/description/icon.png b/addons/website_sale_options/static/description/icon.png
deleted file mode 100644
index 4e8642ae3e8..00000000000
--- a/addons/website_sale_options/static/description/icon.png
+++ /dev/null
Binary files differ
diff --git a/addons/website_sale_options/static/description/icon.svg b/addons/website_sale_options/static/description/icon.svg
deleted file mode 100644
index 9d6a24868d2..00000000000
--- a/addons/website_sale_options/static/description/icon.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#CDC484"/><stop offset="100%" stop-color="#B5AA59"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M3.986 70C.584 70 0 68 0 65V31.978L6 26l8-5h32l18 6-13 10v14L38.632 70H3.986z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><path fill="#000" d="M18 33L6.016 29.586c-1.344-.39-1.413-1.25-.207-2.576l7.28-8.007c.607-.669 1.582-.82 2.925-.454L25 21h19l9.35-2.4c1.1-.4 1.959-.26 2.576.419l7.078 7.786c1.33 1.463 1.327 2.383-.01 2.758L51 33v20c0 .667-.333 1-1 1H19c-.667 0-1-.333-1-1V33zm7.367-10l-6.414 10h31L43.54 23H25.367zm.323 13.954v1.895H23.73v1.896h1.959v8.529h1.958v-8.53h1.959V38.85h-1.959v-1.895H25.69zm7.834 0v7.581h-1.958v1.896h1.958v2.843h1.959V46.43h1.959v-1.896h-1.96v-7.581h-1.958zm8.814 0v5.686H40.38v1.895h1.96v4.739h1.958v-4.739h1.958V42.64h-1.958v-5.686h-1.959z" opacity=".3"/><path fill="#FFF" d="M18 31L6.016 27.586c-1.344-.39-1.413-1.25-.207-2.576l7.28-8.007c.607-.669 1.582-.82 2.925-.454L25 20h19l9.35-3.4c1.1-.4 1.959-.26 2.576.419l7.078 7.786c1.33 1.463 1.327 2.383-.01 2.758L51 31v20c0 .667-.333 1-1 1H19c-.667 0-1-.333-1-1V31zm7.367-10l-6.414 10h31L43.54 21H25.367zm.323 13.954v1.895H23.73v1.896h1.959v8.529h1.958v-8.53h1.959V36.85h-1.959v-1.895H25.69zm7.834 0v7.581h-1.958v1.896h1.958v2.843h1.959V44.43h1.959v-1.896h-1.96v-7.581h-1.958zm8.814 0v5.686H40.38v1.895h1.96v4.739h1.958v-4.739h1.958V40.64h-1.958v-5.686h-1.959z"/></g></g></svg> \ No newline at end of file
diff --git a/addons/website_sale_options/static/src/js/website_sale.test.js b/addons/website_sale_options/static/src/js/website_sale.test.js
deleted file mode 100644
index b982178afc3..00000000000
--- a/addons/website_sale_options/static/src/js/website_sale.test.js
+++ /dev/null
@@ -1,197 +0,0 @@
-odoo.define('website_sale.test', function (require) {
-'use strict';
-
-require("website_sale.tour");
-var tour = require("web_tour.tour");
-var base = require("web_editor.base");
-var ajax = require('web.ajax');
-
-var steps = tour.tours.shop_buy_product.steps;
-for (var k=0; k<steps.length; k++) {
- if (steps[k].content === "click on add to cart") {
- steps.splice(k+1, 0, {
- content: "click in modal on 'Proceed to checkout' button",
- trigger: 'a:contains("Proceed to Checkout")',
- });
- break;
- }
-}
-
-tour.register('shop_customize', {
- test: true,
- url: '/shop',
- wait_for: base.ready()
-},
- [
- {
- content: "open customize menu",
- trigger: '#customize-menu > a',
- },
- {
- content: "click on 'Product Attribute's Filters'",
- trigger: "#customize-menu a:contains(Product Attribute's Filters)",
- },
- {
- content: "select product attribute memory Steel",
- extra_trigger: 'body:not(:has(#customize-menu:visible .dropdown-menu:visible))',
- trigger: 'form.js_attributes label:contains(Steel) input:not(:checked)',
- },
- {
- content: "check the selection",
- trigger: 'form.js_attributes label:contains(Steel) input:checked',
- run: function () {}, // it's a check
- },
- {
- content: "select product",
- extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(2)))',
- trigger: '.oe_product_cart a:contains("Customizable Desk")',
- },
- {
- content: "open customize menu",
- trigger: '#customize-menu > a',
- extra_trigger: "#product_detail",
- },
- {
- content: "click on 'List View of Variants'",
- trigger: "#customize-menu a:contains(List View of Variants)",
- },
- {
- content: "check page loaded after list of variant customization enabled",
- trigger: '.js_product_change',
- run: function () {}, // it's a check
- },
- {
- content: "check price is 750 and set quantity to 2",
- trigger: ".js_product:first input.quantity:propValue(1)",
- extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
- run: "text 2",
- },
- {
- content: "verify pricelist based on quantity has effect",
- trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)",
- run: function () {}, // it's a check
- },
- {
- content: "check pricelit has been applied and switch to Aluminium variant",
- trigger: ".js_product label:contains('Aluminium')",
- },
- {
- content: "verify that price has changed when changing variant",
- trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))",
- run: function () {}, // it's a check
- },
- {
- content: "open customize menu",
- trigger: '#customize-menu > a',
- },
- {
- content: "remove 'List View of Variants'",
- trigger: "#customize-menu a:contains(List View of Variants):has(input:checked)",
- },
- {
- content: "check page loaded after list of variant customization disabled",
- trigger: ".js_product:not(:has(.js_product_change))",
- run: function () {}, // it's a check
- },
- {
- content: "check price is 750 and set quantity to 2",
- trigger: ".js_product:first input.quantity:propValue(1)",
- extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
- run: "text 2",
- },
- {
- content: "verify pricelist based on quantity has effect",
- trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)",
- run: function () {}, // it's a check
- },
- {
- content: "switch to Aluminium variant",
- trigger: ".js_product label:contains('Aluminium')",
- },
- {
- content: "verify that price has changed when changing variant",
- trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))",
- run: function () {}, // it's a check
- },
- {
- content: "switch back to Steel variant",
- trigger: ".js_product label:contains('Steel')",
- },
- {
- content: "click on 'Add to Cart' button",
- trigger: "a:contains(Add to Cart)",
- },
- {
- content: "price is lowered by pricelist and not multiplied by quantity",
- trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(600.00)",
- extra_trigger: "#product_confirmation input.quantity:propValue(2)",
- run: function () {}, // it's a check
- },
- {
- content: "set quantity to 1",
- trigger: "#product_confirmation .js_add_cart_json .fa-minus",
- },
- {
- content: "check that product page has been updated",
- trigger: ".js_product:first input.quantity:propValue(1)",
- extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
- run: function () {}, // it's a check
- },
- {
- content: "check that add to cart modal has been updated",
- trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(750.00)",
- extra_trigger: "#product_confirmation input.quantity:propValue(1)",
- run: function () {}, // it's a check
- },
- {
- content: "add an optional Warranty",
- trigger: ".js_product:contains(Warranty) a:contains(Add to Cart)",
- },
- {
- content: "click in modal on 'Proceed to checkout' button",
- extra_trigger: 'body:has(.js_product:contains(Warranty) a:contains(Add to Cart):hidden)',
- trigger: '.modal-footer a:contains("Proceed to Checkout")',
- },
- {
- content: "check quantity",
- trigger: '.my_cart_quantity:containsExact(2),.o_extra_menu_items .fa-plus',
- run: function () {}, // it's a check
- },
- {
- content: "check optional product",
- trigger: '.optional_product',
- run: function () {}, // it's a check
- },
- {
- content: "remove large cabinet from cart",
- trigger: '#cart_products a.js_add_cart_json:first',
- },
- {
- content: "check optional product is removed",
- trigger: '#wrap:not(:has(.optional_product))',
- run: function () {}, // it's a check
- },
- {
- content: "click on shop",
- trigger: "a:contains(Continue Shopping)",
- extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))',
- },
- {
- content: "open customize menu bis",
- extra_trigger: '#products_grid_before .js_attributes',
- trigger: '#customize-menu > a',
- },
- {
- content: "remove 'Product Attribute's Filters'",
- trigger: "#customize-menu a:contains(Product Attribute's Filters):has(input:checked)",
- },
- {
- content: "finish",
- extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))',
- trigger: '#wrap:not(:has(li:has(.my_cart_quantity):visible))',
- run: function () {}, // it's a check
- },
- ]
-);
-
-});
diff --git a/addons/website_sale_options/tests/__init__.py b/addons/website_sale_options/tests/__init__.py
deleted file mode 100644
index 38533d75c78..00000000000
--- a/addons/website_sale_options/tests/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from . import test_customize \ No newline at end of file
diff --git a/addons/website_sale_options/views/product_views.xml b/addons/website_sale_options/views/product_views.xml
deleted file mode 100644
index c75b2d215e3..00000000000
--- a/addons/website_sale_options/views/product_views.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
-
- <record id="product_template_form_view" model="ir.ui.view">
- <field name="name">product.template.product.website.form</field>
- <field name="model">product.template</field>
- <field name="inherit_id" ref="website_sale.product_template_form_view"/>
- <field name="arch" type="xml">
- <field name="accessory_product_ids" position="after">
- <field name="optional_product_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
- </field>
- </field>
- </record>
-
-</odoo>
diff --git a/addons/website_sale_stock/__manifest__.py b/addons/website_sale_stock/__manifest__.py
index 6eb8f199bec..758886bfd29 100644
--- a/addons/website_sale_stock/__manifest__.py
+++ b/addons/website_sale_stock/__manifest__.py
@@ -2,14 +2,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
- 'name': 'Website Sale Stock - Website Delivery Information',
- 'category': 'Technical Settings',
- 'description': "",
+ 'name': 'Product Availability',
+ 'category': 'Website',
+ 'summary': 'Manage product inventory & availability',
+ 'description': """
+Manage the inventory of your products and display their availability status in your eCommerce store.
+In case of stockout, you can decide to block further sales or to keep selling.
+A default behavior can be selected in the Website settings.
+Then it can be made specific at the product level.
+ """,
'depends': [
'website_sale',
'sale_stock',
],
- 'auto_install': True,
'data': [
'views/product_template_views.xml',
'views/res_config_settings_views.xml',
@@ -19,4 +24,5 @@
'demo': [
'data/website_sale_stock_demo.xml',
],
+ 'auto_install': True,
}
diff --git a/addons/website_sale_stock/controllers/main.py b/addons/website_sale_stock/controllers/main.py
index 587cac2aece..333baa08f83 100644
--- a/addons/website_sale_stock/controllers/main.py
+++ b/addons/website_sale_stock/controllers/main.py
@@ -8,8 +8,8 @@ from odoo.tools.pycompat import izip
class WebsiteSale(WebsiteSale):
- def get_attribute_value_ids(self, product):
- res = super(WebsiteSale, self).get_attribute_value_ids(product)
+ def _get_attribute_value_ids(self, product, reference_product=None, pricelist=None):
+ res = super(WebsiteSale, self)._get_attribute_value_ids(product, reference_product, pricelist)
variant_ids = [r[0] for r in res]
# recordsets conserve the order
for r, variant in izip(res, request.env['product.product'].sudo().browse(variant_ids)):
diff --git a/addons/website_sale_stock_options/static/description/icon.png b/addons/website_sale_stock/static/description/icon.png
index c772bcebbbd..c772bcebbbd 100644
--- a/addons/website_sale_stock_options/static/description/icon.png
+++ b/addons/website_sale_stock/static/description/icon.png
Binary files differ
diff --git a/addons/website_sale_stock_options/static/description/icon.svg b/addons/website_sale_stock/static/description/icon.svg
index b519bd02d02..b519bd02d02 100644
--- a/addons/website_sale_stock_options/static/description/icon.svg
+++ b/addons/website_sale_stock/static/description/icon.svg
diff --git a/addons/website_sale_stock/static/src/js/website_sale_stock.js b/addons/website_sale_stock/static/src/js/website_sale_stock.js
index 134d8c45ad7..d57e09c8c9c 100644
--- a/addons/website_sale_stock/static/src/js/website_sale_stock.js
+++ b/addons/website_sale_stock/static/src/js/website_sale_stock.js
@@ -17,8 +17,8 @@ $('.oe_website_sale').each(function() {
var oe_website_sale = this;
// For options products
- $(oe_website_sale).on('change', 'input[name="add_qty"]', function() {
- $(oe_website_sale).find('ul[data-attribute_value_ids]').trigger('change');
+ $(oe_website_sale).on('change', 'input[name="add_qty"]', function(ev, no_stock_check) {
+ $(oe_website_sale).find('ul[data-attribute_value_ids]').trigger('change', [no_stock_check]);
});
$(oe_website_sale).find('input[name="add_qty"]').trigger('change');
@@ -36,7 +36,11 @@ $('.oe_website_sale').each(function() {
/* Renders a specific message concerning the stock of the product
and its variants on the product website page.
*/
- $(oe_website_sale).on('change', 'ul[data-attribute_value_ids]', function(event) {
+ $(oe_website_sale).on('change', 'ul[data-attribute_value_ids]', function(event, no_stock_check) {
+ if(no_stock_check){
+ return;
+ }
+
var $ul = $(event.target).closest('.js_add_cart_variants');
var $parent = $ul.closest('.js_product');
var variant_ids = $ul.data("attribute_value_ids");
@@ -44,13 +48,14 @@ $('.oe_website_sale').each(function() {
$parent.find('input.js_variant_change:checked, select.js_variant_change').each(function() {
values.push(+$(this).val());
});
+ $parent.find('#add_to_cart').removeClass('disabled out_of_stock');
var list_variant_id = parseInt($parent.find('input.js_product_change:checked').val());
var qty = $parent.find('input[name="add_qty"]').val();
for (var k in variant_ids) {
if (_.isEmpty(_.difference(variant_ids[k][1], values)) ||
variant_ids[k][0] === list_variant_id) {
// clone so permanent object is not modified
- var info = _.clone(variant_ids[k][4]);
+ var info = _.clone(variant_ids[k][6]);
if(_.contains(['always', 'threshold'], info['inventory_availability'])) {
info['virtual_available'] -= parseInt(info['cart_qty']);
if (info['virtual_available'] < 0) {
@@ -58,10 +63,14 @@ $('.oe_website_sale').each(function() {
}
// Handle case when manually write in input
if(qty > info['virtual_available']) {
- $parent.find('input[name="add_qty"]').val(info['virtual_available'] || 1);
+ var $input_add_qty = $parent.find('input[name="add_qty"]');
+ $input_add_qty.val(info['virtual_available'] || 1);
+ // trigger change to update the price but add context to avoid coming back here
+ // and start infinite loop
+ $input_add_qty.trigger('change', [true, "", true]);
}
if(qty > info['virtual_available'] || info['virtual_available'] < 1 || qty < 1) {
- $parent.find('#add_to_cart').addClass('disabled');
+ $parent.find('#add_to_cart').addClass('disabled out_of_stock');
}
// For options products: if qty not available disable add to cart on change variant
$parent.find('.js_add').toggleClass('disabled btn', info['virtual_available'] < 1);
diff --git a/addons/website_sale_stock/views/website_sale_stock_templates.xml b/addons/website_sale_stock/views/website_sale_stock_templates.xml
index 95f75c2ff30..ad7fcff72ce 100644
--- a/addons/website_sale_stock/views/website_sale_stock_templates.xml
+++ b/addons/website_sale_stock/views/website_sale_stock_templates.xml
@@ -57,5 +57,15 @@
</xpath>
</template>
+ <template id="website_sale_stock_modal" inherit_id="sale.product_configurator_configure_optional_products" name="Stocks Modal">
+ <xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes">
+ <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1) if handle_stock and product.type == "product" and product.inventory_availability in ["always", "threshold"] else None</attribute>
+ </xpath>
+ <xpath expr="//div[hasclass('css_quantity')]" position="after">
+ <t t-if="handle_stock">
+ <div class='availability_messages'/>
+ </t>
+ </xpath>
+ </template>
</odoo>
diff --git a/addons/website_sale_stock_options/__init__.py b/addons/website_sale_stock_options/__init__.py
deleted file mode 100644
index 67dee8c60db..00000000000
--- a/addons/website_sale_stock_options/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
diff --git a/addons/website_sale_stock_options/__manifest__.py b/addons/website_sale_stock_options/__manifest__.py
deleted file mode 100644
index 6ddbbadbfc3..00000000000
--- a/addons/website_sale_stock_options/__manifest__.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-{
- 'name': 'Product Availability',
- 'category': 'Website',
- 'summary': 'Manage product inventory & availability',
- 'description': """
-Manage the inventory of your products and display their availability status in your eCommerce store.
-In case of stockout, you can decide to block further sales or to keep selling.
-A default behavior can be selected in the Website settings.
-Then it can be made specific at the product level.
- """,
- 'depends': [
- 'website_sale_stock',
- 'website_sale_options',
- ],
- 'data': [
- 'views/website_sale_stock_templates.xml',
- ],
- 'auto_install': True,
-}
diff --git a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml b/addons/website_sale_stock_options/views/website_sale_stock_templates.xml
deleted file mode 100644
index d5709c8556c..00000000000
--- a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <!-- Shopping Cart Lines -->
- <template id="website_sale_stock_modal" inherit_id="website_sale_options.modal" name="Stocks Modal">
- <xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes">
- <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1) if product.type == "product" and product.inventory_availability in ["always", "threshold"] else None</attribute>
- </xpath>
- <xpath expr="//div[hasclass('css_quantity')]" position="after">
- <div class='availability_messages'/>
- </xpath>
- </template>
-
-</odoo>