[FIX] product,(website_)sale(*): fix product configurator

This commit has several parts:

- fix ordering of attributes and values
- fix/create python logic (notably for checking the possibility of a combination
	and for price computing)
- fix currency
- display correct info on the views without the need of JS (prevent flickering)
- fix various issues with website and SO
- add a lot of tests (both unit and tour)

================================================================================

*** Make consistent the filtering and ordering of product variants ***

Filter & Check possibility
==========================

The first variant shown on the website should always be possible.

When activating the customize "list view of variants", we don't want to show
impossible combination on the list.

We also don't want the user to be able to add to cart an impossible variant.

Show appropriate messages when no combination is possible.

Order
=====

We want the variant order to be consistent with what the user selected with the
sequence fields. The display order on the website should be the same than the
display on the various backend views.

This implies also fixing the order of:
- product.attribute
- product.attribute.value
- product.template.attribute.line
- product.template.attribute.value

The general order is now: (parent,) sequence, id.

Before, it was already a mix of those, but not always in the same order, and not
every key for every model. The name has been removed from the order: either we
sequence them correctly, or we want them in the creation order.

On the website, the first combination shown by the python should be the same as
the one that will be computed by the JS to prevent flickering.

Ideally there should be no RPC at page loading if the python has computed
everything correctly, but it is currently not possible to achieve because the
view logic for website_sale_stock is only in JS.

================================================================================

*** Fix product template and variant price computation ***

Move and improve the get_combination_info method:

- take into account tax included/excluded settings (B2B/B2C)
- take into account pricelist discount policy (striked price)
- fix inconsistencies when price after pricelist would be 0
- use proper rounding and discount compute with the currency helper methods
- fix an issue where context "partner" would have a mix of id and recordset

================================================================================

*** Fix currency conversions ***

The list_price and lst_price of a product are always encoded using the currency
of the product.

The results of "combination info" are always returned using the currency of the
pricelist if given, or the currency of the product.

The prices on an order (line) are encoded with the currency of its pricelist.

================================================================================

*** Fix dynamic variants access rights ***

Public users on the e-commerce should be able to create product variants if the
configuration of the attributes of the product template is set as "dynamic".

This uses "sudo" to bypass create security rules for public users.
Multiple checks are done to ensure that it creates a valid product variant.

================================================================================

*** Miscellaneous bugs, tracebacks and necessary improvements ***

In general:
 - correctly show warning when no variant is possible
 - don't let user add to cart an impossible variant (from optional product
 	modal or from any RPC), both in interface and controller logic
 - fix as much flickering as possible by correctly pre-computing in python/views
 - move business logic from controllers to models
 - correctly add no_variant and is_custom values on the SO line description

Sale order:
 - fix traceback when emptying the product_id field from configurator

website_sale:
 - correctly compute first image / carousel, and correctly swap images when
 	changing variants, without duplicate image or zoom problems
 - don't use list view of variants when it doesn't make sense
 - added missing product info when adding a no_variant attribute to cart
 - fix cart accessory product computation

website_sale_wishlist:
 - fix it with dynamic variants, no_variant and is_custom

website_sale_comparison:
 - fix it with dynamic variants (comparison and alternative products)

website_sale_stock:
 - fix it in general (some flickering still present)

================================================================================

Authored by @seb-odoo with contribution from @awa-odoo

task-1910821

closes odoo/odoo#28079
This commit is contained in:
Sébastien Theys
2018-12-18 16:21:32 +00:00
parent 4e01d5f702
commit f8dad1bb4b
60 changed files with 3135 additions and 888 deletions
+1 -1
View File
@@ -86,6 +86,6 @@ class SaleOrderLine(models.Model):
lang=self.order_id.partner_id.lang,
)
return ticket.get_ticket_multiline_description_sale()
return ticket.get_ticket_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
else:
return super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product)
+1 -1
View File
@@ -291,7 +291,7 @@ class MrpBomLine(models.Model):
@api.onchange('parent_product_tmpl_id')
def onchange_parent_product(self):
return {'domain': {'attribute_value_ids': [
('id', 'in', self.parent_product_tmpl_id.mapped('attribute_line_ids.value_ids.id')),
('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids),
('attribute_id.create_variant', '!=', 'no_variant')
]}}
+6
View File
@@ -128,26 +128,32 @@
<record id="product_attribute_1" model="product.attribute">
<field name="name">Legs</field>
<field name="sequence">1</field>
</record>
<record id="product_attribute_value_1" model="product.attribute.value">
<field name="name">Steel</field>
<field name="attribute_id" ref="product_attribute_1"/>
<field name="sequence">1</field>
</record>
<record id="product_attribute_value_2" model="product.attribute.value">
<field name="name">Aluminium</field>
<field name="attribute_id" ref="product_attribute_1"/>
<field name="sequence">2</field>
</record>
<record id="product_attribute_2" model="product.attribute">
<field name="name">Color</field>
<field name="sequence">2</field>
</record>
<record id="product_attribute_value_3" model="product.attribute.value">
<field name="name">White</field>
<field name="attribute_id" ref="product_attribute_2"/>
<field name="sequence">1</field>
</record>
<record id="product_attribute_value_4" model="product.attribute.value">
<field name="name">Black</field>
<field name="attribute_id" ref="product_attribute_2"/>
<field name="sequence">2</field>
</record>
<record id="product_attribute_3" model="product.attribute">
+6
View File
@@ -2008,6 +2008,12 @@ msgstr ""
msgid "The number of variants to generate is too high. You should either not generate variants for each combination or generate them on demand from the sales order. To do so, open the form view of attributes and change the mode of *Create Variants*."
msgstr ""
#. module: product
#: code:addons/product/models/product_attribute.py
#, python-format
msgid "You are trying to change the type of an attribute value still referenced on at least one product template: %s"
msgstr ""
#. module: product
#: code:addons/product/models/product_attribute.py:56
#, python-format
+57 -8
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
from odoo import api, fields, models, tools, _
@@ -11,6 +12,8 @@ from odoo.addons import decimal_precision as dp
from odoo.tools import float_compare, pycompat
_logger = logging.getLogger(__name__)
class ProductCategory(models.Model):
_name = "product.category"
@@ -83,13 +86,16 @@ class ProductProduct(models.Model):
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'default_code, name, id'
# price: total price, context dependent (partner, pricelist, quantity)
price = fields.Float(
'Price', compute='_compute_product_price',
digits=dp.get_precision('Product Price'), inverse='_set_product_price')
# price_extra: catalog extra value only, sum of variant extra attributes
price_extra = fields.Float(
'Variant Price Extra', compute='_compute_product_price_extra',
digits=dp.get_precision('Product Price'),
help="This is the sum of the extra price of all attributes")
# lst_price: catalog value + extra, context dependent (uom)
lst_price = fields.Float(
'Sale Price', compute='_compute_product_lst_price',
digits=dp.get_precision('Product Price'), inverse='_set_product_lst_price',
@@ -163,8 +169,8 @@ class ProductProduct(models.Model):
pricelist_id_or_name = self._context.get('pricelist')
if pricelist_id_or_name:
pricelist = None
partner = self._context.get('partner', False)
quantity = self._context.get('quantity', 1.0)
partner = self.env.context.get('partner', False)
quantity = self.env.context.get('quantity', 1.0)
# Support context pricelists specified as display_name or ID for compatibility
if isinstance(pricelist_id_or_name, pycompat.string_types):
@@ -278,11 +284,29 @@ class ProductProduct(models.Model):
else:
self.product_tmpl_id.image = image
@api.depends('product_tmpl_id', 'attribute_value_ids')
def _compute_product_template_attribute_value_ids(self):
# Fetch and pre-map the values first for performance. It assumes there
# won't be too many values, but there might be a lot of products.
values = self.env['product.template.attribute.value'].search([
('product_tmpl_id', 'in', self.mapped('product_tmpl_id').ids),
('product_attribute_value_id', 'in', self.mapped('attribute_value_ids').ids),
])
values_per_template = {}
for ptav in values:
pt_id = ptav.product_tmpl_id.id
if pt_id not in values_per_template:
values_per_template[pt_id] = {}
values_per_template[pt_id][ptav.product_attribute_value_id.id] = ptav
for product in self:
product.product_template_attribute_value_ids = self.env['product.template.attribute.value']._search([
('product_tmpl_id', '=', product.product_tmpl_id.id),
('product_attribute_value_id', 'in', product.attribute_value_ids.ids)])
product.product_template_attribute_value_ids = self.env['product.template.attribute.value']
for pav in product.attribute_value_ids:
if product.product_tmpl_id.id not in values_per_template or pav.id not in values_per_template[product.product_tmpl_id.id]:
_logger.warning("A matching product.template.attribute.value was not found for the product.attribute.value #%s on the template #%s" % (pav.id, product.product_tmpl_id.id))
else:
product.product_template_attribute_value_ids += values_per_template[product.product_tmpl_id.id][pav.id]
@api.one
def _get_pricelist_items(self):
@@ -390,7 +414,7 @@ class ProductProduct(models.Model):
result = []
for product in self.sudo():
# display only the attributes with multiple possible values on the template
variable_attributes = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id')
variable_attributes = product.product_tmpl_id._get_valid_product_template_attribute_lines().filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id')
variant = product.attribute_value_ids._variant_name(variable_attributes)
name = variant and "%s (%s)" % (product.name, variant) or product.name
@@ -610,9 +634,34 @@ class ProductProduct(models.Model):
:return: True if the attibutes and values are correct, False instead
"""
self.ensure_one()
values = self.attribute_value_ids.filtered(lambda v: v.attribute_id.create_variant != 'no_variant')
values = self.attribute_value_ids
attributes = values.mapped('attribute_id')
return attributes == valid_attributes and values <= valid_values
if attributes != valid_attributes:
return False
for value in values:
if value not in valid_values:
return False
return True
@api.multi
def _is_variant_possible(self, parent_combination=None):
"""Return whether the variant is possible based on its own combination,
and optionally a parent combination.
See `_is_combination_possible` for more information.
This will always exclude variants for templates that have `no_variant`
attributes because the variant itself will not be the full combination.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: ẁhether the variant is possible based on its own combination
:rtype: bool
"""
self.ensure_one()
return self.product_tmpl_id._is_combination_possible(self.product_template_attribute_value_ids, parent_combination=parent_combination)
class ProductPackaging(models.Model):
+58 -10
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.osv import expression
from odoo import api, fields, models, _
from odoo.addons import decimal_precision as dp
from odoo.exceptions import UserError, ValidationError
@@ -11,11 +10,13 @@ from odoo.osv import expression
class ProductAttribute(models.Model):
_name = "product.attribute"
_description = "Product Attribute"
_order = 'sequence, name'
# if you change this _order, keep it in sync with the method
# `_sort_key_attribute_value` in `product.template`
_order = 'sequence, id'
name = fields.Char('Attribute', required=True, translate=True)
value_ids = fields.One2many('product.attribute.value', 'attribute_id', 'Values', copy=True)
sequence = fields.Integer('Sequence', help="Determine the display order")
sequence = fields.Integer('Sequence', help="Determine the display order", index=True)
attribute_line_ids = fields.One2many('product.template.attribute.line', 'attribute_id', 'Lines')
create_variant = fields.Selection([
('no_variant', 'Never'),
@@ -25,15 +26,43 @@ class ProductAttribute(models.Model):
string="Create Variants",
help="Check this if you want to create multiple variants for this attribute.", required=True)
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda pa: pa.create_variant != 'no_variant')
@api.multi
def write(self, vals):
"""Override to make sure attribute type can't be changed if it's used on
a product template.
This is important to prevent because changing the type would make
existing combinations invalid without recomputing them, and recomputing
them might take too long and we don't want to change products without
the user knowing about it."""
if 'create_variant' in vals:
products = self._get_related_product_templates()
if products:
message = ', '.join(products.mapped('name'))
raise UserError(_('You are trying to change the type of an attribute value still referenced on at least one product template: %s') % message)
return super(ProductAttribute, self).write(vals)
@api.multi
def _get_related_product_templates(self):
return self.env['product.template'].with_context(active_test=False).search([
('attribute_line_ids.attribute_id', 'in', self.ids),
])
class ProductAttributeValue(models.Model):
_name = "product.attribute.value"
# if you change this _order, keep it in sync with the method
# `_sort_key_variant` in `product.template'
_order = 'attribute_id, sequence, id'
_description = 'Attribute Value'
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)
sequence = fields.Integer(string='Sequence', help="Determine the display order", index=True)
attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='cascade', required=True, index=True)
_sql_constraints = [
('value_company_uniq', 'unique (name, attribute_id)', 'This attribute value already exists !')
@@ -51,11 +80,21 @@ class ProductAttributeValue(models.Model):
@api.multi
def unlink(self):
linked_products = self.env['product.product'].with_context(active_test=False).search([('attribute_value_ids', 'in', self.ids)])
linked_products = self._get_related_product_templates()
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()
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda pav: pav.attribute_id.create_variant != 'no_variant')
@api.multi
def _get_related_product_templates(self):
return self.env['product.template'].with_context(active_test=False).search([
('attribute_line_ids.value_ids', 'in', self.ids),
])
class ProductTemplateAttributeLine(models.Model):
"""Attributes available on product.template with their selected values in a m2m.
@@ -64,9 +103,10 @@ class ProductTemplateAttributeLine(models.Model):
_name = "product.template.attribute.line"
_rec_name = 'attribute_id'
_description = 'Product Template Attribute Line'
_order = 'attribute_id, id'
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)
product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True, index=True)
attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True, index=True)
value_ids = fields.Many2many('product.attribute.value', string='Attribute Values')
product_template_value_ids = fields.Many2many(
'product.template.attribute.value',
@@ -76,7 +116,7 @@ class ProductTemplateAttributeLine(models.Model):
@api.constrains('value_ids', 'attribute_id')
def _check_valid_attribute(self):
if any(line.value_ids > line.attribute_id.value_ids for line in self):
if any(not line.value_ids or line.value_ids > line.attribute_id.value_ids for line in self):
raise ValidationError(_('You cannot use this attribute with the following value.'))
return True
@@ -152,13 +192,17 @@ class ProductTemplateAttributeLine(models.Model):
return self.browse(attribute_ids).name_get()
return super(ProductTemplateAttributeLine, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda ptal: ptal.attribute_id.create_variant != 'no_variant')
class ProductTemplateAttributeValue(models.Model):
"""Materialized relationship between attribute values
and product template generated by the product.template.attribute.line"""
_name = "product.template.attribute.value"
_order = 'sequence, attribute_id, id'
_order = 'product_attribute_value_id, id'
_description = 'Product Attribute Value'
name = fields.Char('Value', related="product_attribute_value_id.name")
@@ -192,6 +236,10 @@ class ProductTemplateAttributeValue(models.Model):
return super(ProductTemplateAttributeValue, self).name_get()
return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self]
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda ptav: ptav.attribute_id.create_variant != 'no_variant')
class ProductTemplateAttributeExclusion(models.Model):
_name = "product.template.attribute.exclusion"
+419 -87
View File
@@ -2,15 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
import operator
import psycopg2
from odoo.addons import decimal_precision as dp
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError, RedirectWarning, except_orm
from odoo.exceptions import ValidationError, RedirectWarning, UserError
from odoo.osv import expression
from odoo.tools import pycompat
from odoo.exceptions import UserError
class ProductTemplate(models.Model):
@@ -61,13 +59,16 @@ class ProductTemplate(models.Model):
'res.currency', 'Currency', compute='_compute_currency_id')
# price fields
# price: total template price, context dependent (partner, pricelist, quantity)
price = fields.Float(
'Price', compute='_compute_template_price', inverse='_set_template_price',
digits=dp.get_precision('Product Price'))
# list_price: catalog price, user defined
list_price = fields.Float(
'Sales Price', default=1.0,
digits=dp.get_precision('Product Price'),
help="Price at which the product is sold to customers.")
# lst_price: catalog price for template, but including extra for variants
lst_price = fields.Float(
'Public Price', related='list_price', readonly=False,
digits=dp.get_precision('Product Price'))
@@ -174,8 +175,8 @@ class ProductTemplate(models.Model):
pricelist_id_or_name = self._context.get('pricelist')
if pricelist_id_or_name:
pricelist = None
partner = self._context.get('partner')
quantity = self._context.get('quantity', 1.0)
partner = self.env.context.get('partner')
quantity = self.env.context.get('quantity', 1.0)
# Support context pricelists specified as display_name or ID for compatibility
if isinstance(pricelist_id_or_name, pycompat.string_types):
@@ -435,14 +436,16 @@ class ProductTemplate(models.Model):
def create_variant_ids(self):
Product = self.env["product.product"]
variants_to_create = []
variants_to_activate = []
variants_to_unlink = []
for tmpl_id in self.with_context(active_test=False):
# Handle the variants for each template separately. This will be
# less efficient when called on a lot of products with few variants
# but it is better when there's a lot of variants on one template.
variants_to_create = []
variants_to_activate = self.env['product.product']
variants_to_unlink = self.env['product.product']
# adding an attribute with only one value should not recreate product
# write this attribute on every product to make sure we don't lose them
variant_alone = tmpl_id.attribute_line_ids.filtered(lambda line: line.attribute_id.create_variant == 'always' and len(line.value_ids) == 1).mapped('value_ids')
variant_alone = tmpl_id._get_valid_product_template_attribute_lines().filtered(lambda line: line.attribute_id.create_variant == 'always' and len(line.value_ids) == 1).mapped('value_ids')
for value_id in variant_alone:
updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id'))
updated_products.write({'attribute_value_ids': [(4, value_id.id)]})
@@ -452,15 +455,15 @@ class ProductTemplate(models.Model):
# process.
# Technical note: if there is no attribute, a variant is still created because
# 'not any([])' and 'set([]) not in set([])' are True.
if not any(attrib.create_variant == 'dynamic' for attrib in tmpl_id.mapped('attribute_line_ids.attribute_id')):
# Iterator containing all possible attribute values combination
if not tmpl_id.has_dynamic_attributes():
# Iterator containing all possible `product.attribute.value` combination
# The iterator is used to avoid MemoryError in case of a huge number of combination.
all_variants = itertools.product(*(
line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant'
line.value_ids.ids for line in tmpl_id._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
))
# Set containing existing attribute values combination
# Set containing existing `product.attribute.value` combination
existing_variants = {
frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids)
frozenset(variant.attribute_value_ids.ids)
for variant in tmpl_id.product_variant_ids
}
# For each possible variant, create if it doesn't exist yet.
@@ -473,107 +476,436 @@ class ProductTemplate(models.Model):
})
if len(variants_to_create) > 1000:
raise UserError(_(
'The number of variants to generate is too high. '
'You should either not generate variants for each combination or generate them on demand from the sales order. '
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
'The number of variants to generate is too high. '
'You should either not generate variants for each combination or generate them on demand from the sales order. '
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
# Check existing variants if any needs to be activated or unlinked.
# - if the product is not active and has valid attributes and attribute values, it
# should be activated
# - if the product does not have valid attributes or attribute values, it should be
# deleted
valid_value_ids = tmpl_id.mapped('attribute_line_ids.value_ids').filtered(
lambda v: v.attribute_id.create_variant != 'no_variant'
)
valid_attribute_ids = valid_value_ids.mapped('attribute_id')
valid_value_ids = tmpl_id._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = tmpl_id._get_valid_product_attributes()._without_no_variant_attributes()
for product_id in tmpl_id.product_variant_ids:
if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids):
if not product_id.active:
variants_to_activate.append(product_id)
variants_to_activate += product_id
else:
variants_to_unlink.append(product_id)
variants_to_unlink += product_id
if variants_to_activate:
Product.concat(*variants_to_activate).write({'active': True})
if variants_to_activate:
variants_to_activate.write({'active': True})
# create new products
if variants_to_create:
Product.create(variants_to_create)
# create new products
if variants_to_create:
Product.create(variants_to_create)
# unlink or inactive product
for variant in variants_to_unlink:
# unlink or inactive product
# try in batch first because it is much faster
try:
with self._cr.savepoint(), tools.mute_logger('odoo.sql_db'):
variant.unlink()
# We catch all kind of exception to be sure that the operation doesn't fail.
except (psycopg2.Error, except_orm):
variant.write({'active': False})
pass
variants_to_unlink.unlink()
except Exception:
# fall back to one by one if batch is not possible
for variant in variants_to_unlink:
try:
with self._cr.savepoint(), tools.mute_logger('odoo.sql_db'):
variant.unlink()
# We catch all kind of exception to be sure that the operation doesn't fail.
except Exception:
# Note: this can still fail if something is preventing from archiving.
# This is the case from existing stock reordering rules.
variant.write({'active': False})
return True
def has_dynamic_attributes(self):
return self.attribute_line_ids and \
any(create_variant == 'dynamic'
for create_variant in self.mapped('attribute_line_ids.value_ids.attribute_id.create_variant'))
"""Return whether this `product.template` has at least one dynamic
attribute.
:return: True if at least one dynamic attribute, False otherwise
:rtype: bool
"""
self.ensure_one()
return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes())
@api.multi
def _get_valid_product_template_attribute_lines(self):
"""A product template attribute line is considered valid if it has at
least one possible value.
Those with only one value are considered valid, even though they should
not appear on the configurator itself (unless they have an is_custom
value to input), indeed single value attributes can be used to filter
products among others based on that attribute/value.
This method is necessary because it was previously possible to save a
line without any value on it, so the database might not be consistent in
that regard.
:return: all the valid product template attribute lines of this template
:rtype: recordset `product.template.attribute.line`
"""
self.ensure_one()
return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
@api.multi
def _get_valid_product_attributes(self):
"""A product attribute is considered valid for a template if it
has at least one possible value set on the template.
See `_get_valid_product_template_attribute_lines`.
:return: all the valid product attributes of this template
:rtype: recordset `product.attribute`
"""
self.ensure_one()
product_attributes = self.env['product.attribute']
for ptal in self._get_valid_product_template_attribute_lines():
product_attributes |= ptal.attribute_id
return product_attributes
@api.multi
def _get_valid_product_attribute_values(self):
"""A product attribute value is considered valid for a template if it is
defined on a product template attribute line.
:return: all the valid product attribute values of this template
:rtype: recordset `product.attribute.value`
"""
self.ensure_one()
return self._get_valid_product_template_attribute_lines().mapped('value_ids')
@api.multi
def _get_possible_variants(self, parent_combination=None):
"""Return the existing variants that are possible.
For dynamic attributes, it will only return the variants that have been
created already. For no_variant attributes, it will return an empty
recordset because the variants themselves are not a full combination.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: the existing variants that are possible.
:rtype: recordset of `product.product`
"""
self.ensure_one()
return self.product_variant_ids.filtered(lambda p: p._is_variant_possible(parent_combination))
@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
"""
"""deprecated, use _get_possible_variants instead"""
self.ensure_one()
product_template_attribute_values = self.env['product.template.attribute.value'].search([('product_tmpl_id', '=', self.id)])
parent_combination = self.env['product.template.attribute.value']
if reference_product:
# append the reference_product if provided
product_template_attribute_values |= reference_product.product_template_attribute_value_ids
if reference_product._context.get('no_variant_attribute_values'):
parent_combination |= reference_product.product_template_attribute_value_ids
if reference_product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
product_template_attribute_values |= reference_product._context.get('no_variant_attribute_values')
parent_combination |= reference_product.env.context.get('no_variant_attribute_values')
return self._get_possible_variants(parent_combination)
product_variants = self.product_variant_ids
for product_template_attribute_value in product_template_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_template_attribute_value.product_tmpl_id != self \
and self in product_template_attribute_value.exclude_for.mapped('product_tmpl_id') \
and any(not exclude_for.value_ids
for exclude_for in product_template_attribute_value.exclude_for.filtered(
lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self)):
return self.env['product.product']
@api.multi
def _get_attribute_exclusions(self, parent_combination=None):
"""Return the list of attribute exclusions of a product.
# CASE 2: Check if some of the product.template.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_template_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 exclusion
# i.e: the restriction 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_template_attribute_value.product_tmpl_id != self or product_template_attribute_value in variant.product_template_attribute_value_ids) and
# 2/ Check the variant has one of the excluded attribute values
any(attribute_value in excluded.value_ids for attribute_value in variant.product_template_attribute_value_ids))
:param parent_combination: the combination from which
`self` is an optional or accessory product. Indeed exclusions
rules on one product can concern another product.
:type parent_combination: recordset `product.template.attribute.value`
return product_variants
:return: dict of exclusions
- exclusions: from this product itself
- parent_exclusions: from the parent_combination
- archived_combinations: variants that are archived
- existing_combinations: variants that are existing (as opposed to
deleted, because deleted need to be considered impossible if
there are no dynamic attributes).
- has_dynamic_attributes: whether there is a dynamic attribute
- no_variant_product_template_attribute_value_ids: values that are
no_variant
"""
self.ensure_one()
return {
'exclusions': self._get_own_attribute_exclusions(),
'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination),
'archived_combinations': self._get_archived_combinations(),
'has_dynamic_attributes': self.has_dynamic_attributes(),
'existing_combinations': self._get_existing_combinations(),
'no_variant_product_template_attribute_value_ids': self._get_no_variant_product_template_attribute_values(),
}
@api.multi
def _get_own_attribute_exclusions(self):
"""Get exclusions coming from the current template.
Dictionnary, each ptav is a key, and for each of them the value is
an array with the other ptav that they exclude (empty if no exclusion).
"""
self.ensure_one()
product_template_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids')
return {
ptav.id: [
value_id
for filter_line in ptav.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == self
) for value_id in filter_line.value_ids.ids
]
for ptav in product_template_attribute_values
}
@api.multi
def _get_parent_attribute_exclusions(self, parent_combination):
"""Get exclusions coming from the parent combination.
Array, each element is a ptav that is excluded because of the parent.
"""
self.ensure_one()
if not parent_combination:
return []
return [
value_id
for filter_line in parent_combination.mapped('exclude_for').filtered(
lambda filter_line: filter_line.product_tmpl_id == self
) for value_id in filter_line.value_ids.ids
]
@api.multi
def _get_archived_combinations(self):
self.ensure_one()
"""Get archived combinations.
Array, each element is an array with ids of an archived combination.
"""
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
# Search only among those having the right set of attributes.
domain = [('product_tmpl_id', '=', self.id), ('active', '=', False)]
for pa in valid_attribute_ids:
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
archived_variants = self.env['product.product'].search(domain)
archived_variants = archived_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
return [archived_variant.product_template_attribute_value_ids.ids
for archived_variant in archived_variants]
@api.multi
def _get_existing_combinations(self):
self.ensure_one()
"""Get existing combinations.
Needed because when not using dynamic attributes, the combination is
not ok if it doesn't exist (= if the variant has been deleted).
Array, each element is an array with ids of an existing combination.
"""
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
# Search only among those having the right set of attributes.
domain = [('product_tmpl_id', '=', self.id), ('active', '=', True)]
for pa in valid_attribute_ids:
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
existing_variants = self.env['product.product'].search(domain)
existing_variants = existing_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
return [variant.product_template_attribute_value_ids.ids
for variant in existing_variants]
@api.multi
def _get_no_variant_product_template_attribute_values(self):
self.ensure_one()
product_template_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids')
return product_template_attribute_values.filtered(
lambda v: v.attribute_id.create_variant == 'no_variant'
).ids
@api.multi
def _is_combination_possible(self, combination, parent_combination=None):
"""
The combination is possible if it is not excluded by any rule
coming from the current template, not excluded by any rule from the
parent_combination (if given), and there should not be any archived
variant with the exact same combination.
If the template does not have any dynamic attribute, the combination
is also not possible if the matching variant has been deleted.
Moreover the attributes of the combination must excatly match the
attributes allowed on the template.
:param combination: the combination to check for possibility
:type combination: recordset `product.template.attribute.value`
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: whether the combination is possible
:rtype: bool
"""
self.ensure_one()
if len(combination) != len(self._get_valid_product_template_attribute_lines()):
# number of attribute values passed is different than the
# configuration of attributes on the template
return False
if self._get_valid_product_attributes() != combination.mapped('attribute_id'):
# combination has different attributes than the ones configured on the template
return False
variant = self._get_variant_for_combination(combination)
if not self.has_dynamic_attributes() and not variant:
# the variant has been deleted
return False
exclusions = self._get_own_attribute_exclusions()
if exclusions:
# exclude if the current value is in an exclusion,
# and the value excluding it is also in the combination
for ptav in combination:
for exclusion in exclusions.get(ptav.id):
if exclusion in combination.ids:
return False
parent_exclusions = self._get_parent_attribute_exclusions(parent_combination)
if parent_exclusions:
for exclusion in parent_exclusions:
if exclusion in combination.ids:
return False
filtered_combination = combination._without_no_variant_attributes()
archived_combinations = self._get_archived_combinations()
if archived_combinations and filtered_combination.ids in archived_combinations:
return False
return True
@api.multi
def _get_variant_for_combination(self, combination):
"""Get the variant matching the combination.
All of the values in combination must be present in the variant, and the
variant should not have more attributes. Ignore the attributes that are
not supposed to create variants.
:param combination: recordset of `product.template.attribute.value`
:return: the variant if found, else empty
:rtype: recordset `product.product`
"""
self.ensure_one()
filtered_combination = combination._without_no_variant_attributes()
# If there are a lot of variants on this template, it is much faster to
# build a query than using the existing o2m.
domain = [('product_tmpl_id', '=', self.id)]
for ptav in filtered_combination:
domain = expression.AND([[('attribute_value_ids.id', '=', ptav.product_attribute_value_id.id)], domain])
res = self.env['product.product'].search(domain)
# The domain above is checking for the `product.attribute.value`, but we
# need to make sure it's the same `product.template.attribute.value`.
# Also there should theorically be only 0 or 1 but an existing database
# might not be consistent so we need to make sure to take max 1.
return res.filtered(
lambda v: v.product_template_attribute_value_ids == filtered_combination
)[:1]
@api.multi
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
"""
Iterate the attributes and values in order and stop at the first
combination of values that is possible.
When encountering an impossible combination, try to change the
value of latest attributes first.
Ignore attributes that have no values.
Note this method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:param necessary_values: values that must be in the returned combination
:type necessary_values: recordset of `product.template.attribute.value`
:return: the first possible combination found, or empty if none possible
:rtype: recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
return self.env['product.template.attribute.value']
attribute_lines = self._get_valid_product_template_attribute_lines()
def iterate_attribute_lines(attribute_lines, combination):
"""
:param attribute_lines: recordset of product.template.attribute.line
that are still to iterate
:param combination: recordset of product.template.attribute.value
that have to be tested for possibility
:return: the first possible combination found, or empty
:rtype: recordset of `product.template.attribute.value`
"""
if not attribute_lines:
if self._is_combination_possible(combination, parent_combination):
return combination
else:
return self.env['product.template.attribute.value']
for cur in attribute_lines[0].product_template_value_ids:
res = iterate_attribute_lines(attribute_lines[1:], combination + cur)
if res and all(v in res for v in (necessary_values or [])):
return res
return self.env['product.template.attribute.value']
return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value'])
@api.multi
def _get_closest_possible_combination(self, combination):
"""Get the first possible combination that is the closest to the given
combination.
If the given combination is incomplete, try to complete it.
If the given combination is invalid, try to remove values from it before
completing it.
See `_get_first_possible_combination` note about empty result.
:param combination: the values to include if they are possible
:type combination: recordset `product.template.attribute.value`
:return: the first possible combination that is including as much
elements as possible from the given combination.
:rtype: recordset of product.template.attribute.value
"""
while True:
result = self._get_first_possible_combination(necessary_values=combination)
if result or not combination:
return result
combination = combination[:-1]
@api.model
def get_empty_list_help(self, help):
+1
View File
@@ -73,6 +73,7 @@ class TestProductCommon(common.SavepointCase):
'uom_po_id': cls.uom_unit.id,
'attribute_line_ids': [(0, 0, {
'attribute_id': cls.prod_att_1.id,
'value_ids': [(6, 0, [cls.prod_attr1_v1.id, cls.prod_attr1_v2.id])]
})]
})
cls.product_7 = Product.create({
@@ -1,15 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
class TestProductAttributeValueConfig(TransactionCase):
class TestProductAttributeValueSetup(TransactionCase):
def setUp(self):
super(TestProductAttributeValueConfig, self).setUp()
super(TestProductAttributeValueSetup, self).setUp()
self.computer = self.env['product.template'].create({
'name': 'Super Computer'
'name': 'Super Computer',
'price': 2000,
})
self._add_ssd_attribute()
@@ -26,6 +28,150 @@ class TestProductAttributeValueConfig(TransactionCase):
self.computer_case.create_variant_ids()
def _add_ssd_attribute(self):
self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory', 'sequence': 1})
self.ssd_256 = self.env['product.attribute.value'].create({
'name': '256 GB',
'attribute_id': self.ssd_attribute.id,
'sequence': 1,
})
self.ssd_512 = self.env['product.attribute.value'].create({
'name': '512 GB',
'attribute_id': self.ssd_attribute.id,
'sequence': 2,
})
self._add_ssd_attribute_line()
def _add_ssd_attribute_line(self):
self.computer_ssd_attribute_lines = self.env['product.template.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])],
})
self.computer_ssd_attribute_lines.product_template_value_ids[0].price_extra = 200
self.computer_ssd_attribute_lines.product_template_value_ids[1].price_extra = 400
def _add_ram_attribute(self):
self.ram_attribute = self.env['product.attribute'].create({'name': 'RAM', 'sequence': 2})
self.ram_8 = self.env['product.attribute.value'].create({
'name': '8 GB',
'attribute_id': self.ram_attribute.id,
'sequence': 1,
})
self.ram_16 = self.env['product.attribute.value'].create({
'name': '16 GB',
'attribute_id': self.ram_attribute.id,
'sequence': 2,
})
self.ram_32 = self.env['product.attribute.value'].create({
'name': '32 GB',
'attribute_id': self.ram_attribute.id,
'sequence': 3,
})
self.computer_ram_attribute_lines = self.env['product.template.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])],
})
self.computer_ram_attribute_lines.product_template_value_ids[0].price_extra = 20
self.computer_ram_attribute_lines.product_template_value_ids[1].price_extra = 40
self.computer_ram_attribute_lines.product_template_value_ids[2].price_extra = 80
def _add_hdd_attribute(self):
self.hdd_attribute = self.env['product.attribute'].create({'name': 'HDD', 'sequence': 3})
self.hdd_1 = self.env['product.attribute.value'].create({
'name': '1 To',
'attribute_id': self.hdd_attribute.id,
'sequence': 1,
})
self.hdd_2 = self.env['product.attribute.value'].create({
'name': '2 To',
'attribute_id': self.hdd_attribute.id,
'sequence': 2,
})
self.hdd_4 = self.env['product.attribute.value'].create({
'name': '4 To',
'attribute_id': self.hdd_attribute.id,
'sequence': 3,
})
self._add_hdd_attribute_line()
def _add_hdd_attribute_line(self):
self.computer_hdd_attribute_lines = self.env['product.template.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])],
})
self.computer_hdd_attribute_lines.product_template_value_ids[0].price_extra = 2
self.computer_hdd_attribute_lines.product_template_value_ids[1].price_extra = 4
self.computer_hdd_attribute_lines.product_template_value_ids[2].price_extra = 8
def _add_ram_exclude_for(self):
self._get_product_value_id(self.computer_ram_attribute_lines, self.ram_16).update({
'exclude_for': [(0, 0, {
'product_tmpl_id': self.computer.id,
'value_ids': [(6, 0, [self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_1).id])]
})]
})
def _add_size_attribute(self):
self.size_attribute = self.env['product.attribute'].create({'name': 'Size', 'sequence': 4})
self.size_m = self.env['product.attribute.value'].create({
'name': 'M',
'attribute_id': self.size_attribute.id,
'sequence': 1,
})
self.size_l = self.env['product.attribute.value'].create({
'name': 'L',
'attribute_id': self.size_attribute.id,
'sequence': 2,
})
self.size_xl = self.env['product.attribute.value'].create({
'name': 'XL',
'attribute_id': self.size_attribute.id,
'sequence': 3,
})
self.computer_case_size_attribute_lines = self.env['product.template.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 _get_product_value_id(self, product_template_attribute_lines, product_attribute_value):
return product_template_attribute_lines.product_template_value_ids.filtered(
lambda product_value_id: product_value_id.product_attribute_value_id == product_attribute_value)[0]
def _get_product_template_attribute_value(self, product_attribute_value, model=False):
"""
Return the `product.template.attribute.value` matching
`product_attribute_value` for self.
:param: recordset of one product.attribute.value
:return: recordset of one product.template.attribute.value if found
else empty
"""
if not model:
model = self.computer
return model._get_valid_product_template_attribute_lines().filtered(
lambda l: l.attribute_id == product_attribute_value.attribute_id
).product_template_value_ids.filtered(
lambda v: v.product_attribute_value_id == product_attribute_value
)
def _add_exclude(self, m1, m2, product_template=False):
m1.update({
'exclude_for': [(0, 0, {
'product_tmpl_id': (product_template or self.computer).id,
'value_ids': [(6, 0, [m2.id])]
})]
})
@tagged('post_install', '-at_install')
class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
def test_product_template_attribute_values_creation(self):
self.assertEqual(len(self.computer_ssd_attribute_lines.product_template_value_ids), 2,
'Product attribute values (ssd) were not automatically created')
@@ -36,6 +182,35 @@ class TestProductAttributeValueConfig(TransactionCase):
self.assertEqual(len(self.computer_case_size_attribute_lines.product_template_value_ids), 3,
'Product attribute values (size) were not automatically created')
def test_get_variant_for_combination(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
# completely defined variant
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
ok_variant = self.computer._get_variant_for_combination(combination)
self.assertEqual(ok_variant.product_template_attribute_value_ids, combination)
# over defined variant
combination = computer_ssd_256 + computer_ram_8 + computer_ram_16 + computer_hdd_1
variant = self.computer._get_variant_for_combination(combination)
self.assertEqual(len(variant), 0)
# under defined variant
combination = computer_ssd_256 + computer_ram_8
variant = self.computer._get_variant_for_combination(combination)
self.assertEqual(len(variant), 0)
# also test _has_valid_attributes (case ok):
valid_value_ids = self.computer._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self.computer._get_valid_product_attributes()._without_no_variant_attributes()
self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids))
# also test _has_valid_attributes (case not ok):
self.assertFalse(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids - self.hdd_1))
def test_product_filtered_exclude_for(self):
"""
Super Computer has 18 variants total (2 ssd * 3 ram * 3 hdd)
@@ -45,10 +220,18 @@ class TestProductAttributeValueConfig(TransactionCase):
=> There has to be 16 variants left when filtered
"""
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self.assertEqual(len(self.computer._get_possible_variants()), 18)
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]))
self.assertEqual(len(self.computer._get_possible_variants()), 16)
self.assertTrue(self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)._is_variant_possible())
self.assertFalse(self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_16 + computer_hdd_1)._is_variant_possible())
self.assertFalse(self.computer._get_variant_for_combination(computer_ssd_512 + computer_ram_16 + computer_hdd_1)._is_variant_possible())
def test_children_product_filtered_exclude_for(self):
"""
@@ -59,112 +242,154 @@ class TestProductAttributeValueConfig(TransactionCase):
=> There has to be 2 variants left when filtered
"""
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
computer_size_m = self._get_product_template_attribute_value(self.size_m, self.computer_case)
self._add_exclude(computer_hdd_4, computer_size_m, self.computer_case)
self.assertEqual(len(self.computer_case._get_possible_variants(computer_hdd_4)), 2)
self.assertFalse(self.computer_case._get_variant_for_combination(computer_size_m)._is_variant_possible(computer_hdd_4))
self._add_hdd_excludes_computer_case()
hdd_4_variant = self.computer.product_variant_ids.filtered(
lambda variant: self.hdd_4 in variant.product_template_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 test_is_combination_possible(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self._add_exclude(computer_ram_16, computer_hdd_1)
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
# CASE: basic
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: ram 16 excluding hdd1
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16 + computer_hdd_1))
# CASE: under defined combination
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16))
# CASE: no combination, no variant, just return the only variant
mouse = self.env['product.template'].create({'name': 'Mouse'})
self.assertTrue(mouse._is_combination_possible(self.env['product.template.attribute.value']))
# prep work for the last part of the test
color_attribute = self.env['product.attribute'].create({'name': 'Color'})
color_red = self.env['product.attribute.value'].create({
'name': 'Red',
'attribute_id': color_attribute.id,
})
self.ssd_512 = self.env['product.attribute.value'].create({
'name': '512 GB',
'attribute_id': self.ssd_attribute.id
color_green = self.env['product.attribute.value'].create({
'name': 'Green',
'attribute_id': color_attribute.id,
})
self.computer_ssd_attribute_lines = self.env['product.template.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])]
self.env['product.template.attribute.line'].create({
'product_tmpl_id': mouse.id,
'attribute_id': color_attribute.id,
'value_ids': [(6, 0, [color_red.id, color_green.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.template.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])]
})
mouse.create_variant_ids()
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.template.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])]
})
mouse_color_red = self._get_product_template_attribute_value(color_red, mouse)
mouse_color_green = self._get_product_template_attribute_value(color_green, mouse)
def _add_ram_exclude_for(self):
self.ram_16_excludes_hdd_1 = self.env['product.template.attribute.exclusion'].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])]
})
self._add_exclude(computer_ssd_256, mouse_color_green, mouse)
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.template.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])]
})
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
def _add_hdd_excludes_computer_case(self):
self.hdd_4_excludes_computer_case_m = self.env['product.template.attribute.exclusion'].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])]
})
# CASE: wrong attributes (mouse_color_red not on computer)
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16 + mouse_color_red))
def _get_product_value_id(self, product_template_attribute_lines, product_attribute_value):
return product_template_attribute_lines.product_template_value_ids.filtered(
lambda product_value_id: product_value_id.product_attribute_value_id == product_attribute_value)[0]
# CASE: parent ok
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1, mouse_color_red))
self.assertTrue(mouse._is_combination_possible(mouse_color_red, computer_ssd_256 + computer_ram_8 + computer_hdd_1))
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))
# CASE: parent exclusion but good direction (parent is directional)
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1, mouse_color_green))
# CASE: parent exclusion and wrong direction (parent is directional)
self.assertFalse(mouse._is_combination_possible(mouse_color_green, computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: deleted combination
variant.unlink()
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
def test_get_first_possible_combination(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_ram_32 = self._get_product_template_attribute_value(self.ram_32)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
# ram_8 is allowed with the other attributes
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Below invalidate cache between every test to force reload the
# o2m attribute_line_ids to order it by the new sequence
# Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead
computer_ram_16.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
# Give priority to ram_32 and is allowed with the rest so it should return it
computer_ram_16.product_attribute_value_id.sequence = 2
computer_ram_32.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
# Give priority to ram_16 but now it is not allowing any hdd so it should return ram_8 instead
computer_ram_32.product_attribute_value_id.sequence = 3
computer_ram_16.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_16, computer_hdd_2)
self._add_exclude(computer_ram_16, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Only the last combination is possible
computer_ram_16.product_attribute_value_id.sequence = 2
self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_8, computer_hdd_1)
self._add_exclude(computer_ram_8, computer_hdd_2)
self._add_exclude(computer_ram_8, computer_hdd_4)
self._add_exclude(computer_ram_32, computer_hdd_1)
self._add_exclude(computer_ram_32, computer_hdd_2)
self._add_exclude(computer_ram_32, computer_ssd_256)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
# No possible combination
self._add_exclude(computer_ram_32, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value'])
def test_get_closest_possible_combination(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
# CASE nothing special
self.assertEqual(self.computer._get_closest_possible_combination(None),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE contains computer_hdd_1
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE contains computer_hdd_2
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_2)
# CASE contains computer_hdd_2, computer_ram_16
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2 + computer_ram_16),
computer_ssd_256 + computer_ram_16 + computer_hdd_2)
# CASE invalid combination (excluded):
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1 + computer_ram_16),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE invalid combination (too much):
self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_4)
@@ -27,7 +27,7 @@
<group name="values_ids">
<label for="value_ids" string="Attribute Values"/>
<field name="value_ids" widget="one2many_list" nolabel="1">
<tree string="Values" editable="top">
<tree string="Values" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
</tree>
@@ -49,7 +49,7 @@
<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">
<tree string="Variant Values" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="attribute_id"/>
<field name="name"/>
+71 -147
View File
@@ -8,146 +8,125 @@ 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):
add_qty = int(kw.get('add_qty', 1))
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 deprecated, get it from the pricelist or product directly
'to_currency': to_currency,
'pricelist': pricelist,
'add_qty': add_qty,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions
})
@http.route(['/product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
def show_optional_products(self, product_id, variant_values, pricelist_id, **kw):
return self._show_optional_products(product_id, variant_values, self._get_pricelist(pricelist_id), False, **kw)
pricelist = self._get_pricelist(pricelist_id)
return self._show_optional_products(product_id, variant_values, pricelist, False, **kw)
@http.route(['/product_configurator/optional_product_items'], type='json', auth="user", methods=['POST'])
def optional_product_items(self, product_id, pricelist_id, **kw):
return self._optional_product_items(product_id, self._get_pricelist(pricelist_id), **kw)
pricelist = self._get_pricelist(pricelist_id)
return self._optional_product_items(product_id, pricelist, **kw)
@http.route(['/product_configurator/get_combination_info'], type='json', auth="user", methods=['POST'])
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
return self._get_combination_info(product_template_id, product_id, combination, add_qty, self._get_pricelist(pricelist_id))
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
@http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(product_template_attribute_value_ids)
def _optional_product_items(self, product_id, pricelist, **kw):
add_qty = int(kw.get('add_qty', 1))
product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
to_currency = product.currency_id
if pricelist:
to_currency = pricelist.currency_id
parent_combination = product.product_template_attribute_value_ids
if product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
parent_combination |= product.env.context.get('no_variant_attribute_values')
return request.env['ir.ui.view'].render_template("sale.optional_product_items", {
# product deprecated, it's not used in the view
'product': product,
# reference_product deprecated, use parent_combination instead
'reference_product': product,
'parent_combination': parent_combination,
'pricelist': pricelist,
# to_currency deprecated, get from pricelist or product
'to_currency': to_currency,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
'add_qty': add_qty,
})
def _show_optional_products(self, product_id, variant_values, pricelist, handle_stock, **kw):
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
product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
combination = request.env['product.template.attribute.value'].browse(variant_values)
has_optional_products = product.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination))
if not has_optional_products:
return False
add_qty = int(kw.get('add_qty', 1))
to_currency = (pricelist or 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))
no_variant_attribute_values = request.env['product.template.attribute.value'].browse(variant_values).filtered(
def compute_currency(price):
return product.currency_id._convert(price, to_currency, company, date)
no_variant_attribute_values = combination.filtered(
lambda product_template_attribute_value: product_template_attribute_value.attribute_id.create_variant == 'no_variant'
)
if no_variant_attribute_values:
product = product.with_context(no_variant_attribute_values=no_variant_attribute_values)
has_optional_products = False
for optional_product in product.optional_product_ids:
if optional_product.has_dynamic_attributes() or 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,
'variant_values': variant_values,
'pricelist': pricelist,
'compute_currency': compute_currency,
'to_currency': to_currency,
'handle_stock': handle_stock,
'get_attribute_exclusions': self._get_attribute_exclusions,
})
return request.env['ir.ui.view'].render_template("sale.optional_products_modal", {
'product': product,
'combination': combination,
'add_qty': add_qty,
# reference_product deprecated, use combination instead
'reference_product': product,
'variant_values': variant_values,
'pricelist': pricelist,
# compute_currency deprecated, get from pricelist or product
'compute_currency': compute_currency,
# to_currency deprecated, get from pricelist or product
'to_currency': to_currency,
'handle_stock': handle_stock,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
})
def _get_attribute_exclusions(self, product, reference_product=None):
""" list of attribute exclusions of a product
Args:
- product (product.template): The base product template
- reference_product (product.product): The reference product from which 'product' is an optional or accessory product
:return: dict of exclusions
exclusions.exclusions: exclusions within this product
exclusions.parent_exclusions: exclusions coming from the reference_product
"""
product_attribute_values = request.env['product.template.attribute.value'].search([
('product_tmpl_id', '=', product.id),
('product_attribute_value_id', 'in', product.attribute_line_ids.mapped('value_ids').ids),
])
# array of all the excluded value_ids of all the filter lines for this product
mapped_exclusions = {
product_attribute_value.id: [
value_id
for filter_line in product_attribute_value.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == product
) for value_id in filter_line.value_ids.ids
]
for product_attribute_value in product_attribute_values
}
parent_exclusions = []
"""deprecated, use product method"""
parent_combination = request.env['product.template.attribute.value']
if reference_product:
parent_attribute_value_ids = reference_product.product_template_attribute_value_ids
if parent_attribute_value_ids and reference_product._context.get('no_variant_attribute_values'):
parent_combination |= reference_product.product_template_attribute_value_ids
if reference_product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
parent_attribute_value_ids |= reference_product._context.get('no_variant_attribute_values')
parent_exclusions = [
value_id
for filter_line in parent_attribute_value_ids.mapped('exclude_for').filtered(
lambda filter_line: filter_line.product_tmpl_id == product
) for value_id in filter_line.value_ids.ids]
# Query all archived products for this template
archived_combinations = request.env['product.product'].search(
[('product_tmpl_id', '=', product.id), ('active', '=', False)])
if archived_combinations:
# Old archived variants could have a different set of attributes and are not relevant here
# -> filter them out
attribute_ids = product_attribute_values.mapped('attribute_id')
archived_combinations = archived_combinations.filtered(
lambda product: all(
attribute_id in product.mapped('product_template_attribute_value_ids.attribute_id')
for attribute_id in attribute_ids
)
)
return {
'exclusions': mapped_exclusions,
'parent_exclusions': parent_exclusions,
'archived_combinations': [archived_combination.product_template_attribute_value_ids.ids
for archived_combination in archived_combinations]
}
parent_combination |= reference_product.env.context.get('no_variant_attribute_values')
return product._get_attribute_exclusions(parent_combination)
def _get_product_context(self, pricelist=None, **kw):
"""deprecated, can be removed in master"""
product_context = dict(request.context)
if pricelist:
if not product_context.get('pricelist'):
@@ -157,64 +136,9 @@ class ProductConfiguratorController(http.Controller):
return product_context
def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw):
product_template_attribute_values = request.env['product.template.attribute.value'].browse(combination)
filtered_product_template_attribute_values = product_template_attribute_values.filtered(
lambda product_attribute_value: product_attribute_value.attribute_id.create_variant != 'no_variant'
)
context = {
'quantity': add_qty,
'pricelist': pricelist.id if pricelist else None,
'current_attributes_price_extra': [product_attribute_value.price_extra or 0.0 for product_attribute_value in product_template_attribute_values]
}
"""deprecated, use product method"""
combination = request.env['product.template.attribute.value'].browse(combination)
return request.env['product.template'].browse(product_template_id)._get_combination_info(combination, product_id, add_qty, pricelist)
product_template = request.env['product.template'].with_context(context).browse(product_template_id)
Product = request.env['product.product'].with_context(context)
if product_id and not combination:
product = Product.browse(product_id)
else:
products = Product.search([
('product_tmpl_id', '=', product_template_id)
])
product = products.filtered(
lambda product: all(product_attribute_value in product.product_template_attribute_value_ids
for product_attribute_value in filtered_product_template_attribute_values)
)
product_id = None
list_price = product_template.price_compute('list_price')[product_template.id]
price = product_template.price
if(product):
product = product.with_context(
no_variant_attributes_price_extra=[product_attribute_value.price_extra or 0.0
for product_attribute_value in product_template_attribute_values.filtered(
lambda product_attribute_value: product_attribute_value.attribute_id.create_variant == 'no_variant'
)
]
)
product_id = product.id
list_price = product.price_compute('list_price')[product.id]
price = product.price
display_name = [product_template.name]
if filtered_product_template_attribute_values:
display_name.append(' (')
display_name.append(', '.join(filtered_product_template_attribute_values.mapped('name')))
display_name.append(')')
if pricelist and pricelist.currency_id != product_template.currency_id:
list_price = product_template.currency_id._convert(
list_price, pricelist.currency_id,
request.env.user.company_id, fields.Date.today()
)
return {
'product_id': product_id,
'product_template_id': product_template.id,
'display_name': ''.join(display_name),
'price': price,
'list_price': list_price
}
def _get_pricelist(self, pricelist_id):
return request.env['product.pricelist'].browse(int(pricelist_id)) if pricelist_id and pricelist_id != '0' else None
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
return request.env['product.pricelist'].browse(int(pricelist_id or 0))
+1
View File
@@ -758,6 +758,7 @@ Thanks!</field>
<field name="name">Custom</field>
<field name="attribute_id" ref="product.product_attribute_1"/>
<field name="is_custom">True</field>
<field name="sequence">3</field>
</record>
<record id="product.product_template_attribute_line_1" model="product.template.attribute.line">
+1 -1
View File
@@ -39,7 +39,7 @@ class AccountAnalyticLine(models.Model):
self.ensure_one()
if self.product_id.expense_policy == 'sales_price':
return self.product_id.with_context(
partner=order.partner_id.id,
partner=order.partner_id,
date_order=order.date_order,
pricelist=order.pricelist_id.id,
uom=self.product_uom_id.id
+8
View File
@@ -53,6 +53,14 @@ class ProductProduct(models.Model):
def _get_invoice_policy(self):
return self.invoice_policy
@api.multi
def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False):
"""Return the variant info based on its combination.
See `_get_combination_info` for more information.
"""
self.ensure_one()
return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination)
class ProductAttribute(models.Model):
_inherit = "product.attribute"
+210 -10
View File
@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo.tools.float_utils import float_round
import json
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
@@ -55,21 +59,88 @@ class ProductTemplate(models.Model):
}
return action
def create_product_variant(self, product_template_attribute_value_ids):
@api.multi
def _create_product_variant(self, combination, log_warning=False):
""" Create if necessary and possible and return the product variant
matching the given combination for this template.
It is possible to create only if the template has dynamic attributes
and the combination itself is possible.
:param combination: the combination for which to get or create variant.
The combination must contain all necessary attributes, including
those of type no_variant. Indeed even though those attributes won't
be included in the variant if newly created, they are needed when
checking if the combination is possible.
:type combination: recordset of `product.template.attribute.value`
:param log_warning: whether a warning should be logged on fail
:type log_warning: bool
:return: the product variant matching the combination or none
:rtype: recordset of `product.product`
"""
self.ensure_one()
attribute_value_ids = \
self.env['product.template.attribute.value'] \
.browse(json.loads(product_template_attribute_value_ids)) \
.mapped('product_attribute_value_id') \
.filtered(lambda attribute_value_id: attribute_value_id.attribute_id.create_variant != 'no_variant')
Product = self.env['product.product']
product_variant = self.env['product.product'].create({
product_variant = self._get_variant_for_combination(combination)
if product_variant:
return product_variant
if not self.has_dynamic_attributes():
if log_warning:
_logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id))
return Product
if not self._is_combination_possible(combination):
if log_warning:
_logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id))
return Product
attribute_values = combination.mapped('product_attribute_value_id')._without_no_variant_attributes()
return Product.sudo().create({
'product_tmpl_id': self.id,
'attribute_value_ids': [(6, 0, attribute_value_ids.ids)]
'attribute_value_ids': [(6, 0, attribute_values.ids)]
})
return product_variant.id
@api.multi
def create_product_variant(self, product_template_attribute_value_ids):
""" Create if necessary and possible and return the id of the product
variant matching the given combination for this template.
Note AWA: Known "exploit" issues with this method:
- This method could be used by an unauthenticated user to generate a
lot of useless variants. Unfortunately, after discussing the
matter with ODO, there's no easy and user-friendly way to block
that behavior.
We would have to use captcha/server actions to clean/... that
are all not user-friendly/overkill mechanisms.
- This method could be used to try to guess what product variant ids
are created in the system and what product template ids are
configured as "dynamic", but that does not seem like a big deal.
The error messages are identical on purpose to avoid giving too much
information to a potential attacker:
- returning 0 when failing
- returning the variant id whether it already existed or not
:param product_template_attribute_value_ids: the combination for which
to get or create variant
:type product_template_attribute_value_ids: json encoded list of id
of `product.template.attribute.value`
:return: id of the product variant matching the combination or 0
:rtype: int
"""
combination = self.env['product.template.attribute.value'] \
.browse(json.loads(product_template_attribute_value_ids))
return self._create_product_variant(combination, log_warning=True).id or 0
@api.onchange('type')
def _onchange_type(self):
@@ -93,3 +164,132 @@ class ProductTemplate(models.Model):
'template': '/sale/static/xls/product_pricelist_several.xls'
}]
return res
@api.multi
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
actually possible.
:param combination: recordset of `product.template.attribute.value`
:param product_id: id of a `product.product`. If no `combination`
is set, the method will try to load the variant `product_id` if
it exists instead of finding a variant based on the combination.
If there is no combination, that means we definitely want a
variant and not something that will have no_variant set.
:param add_qty: float with the quantity for which to get the info,
indeed some pricelist rules might depend on it.
:param pricelist: `product.pricelist` the pricelist to use
(can be none, eg. from SO if no partner and no pricelist selected)
:param parent_combination: if no combination and no product_id are
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
- product_template_id: the current template id
- display_name: the name of the combination
- price: the computed price of the combination, take the catalog
price if no pricelist is given
- list_price: the catalog price of the combination, but this is
not the "real" list_price, it has price_extra included (so
it's actually more closely related to `lst_price`), and it
is converted to the pricelist currency (if given)
- has_discounted_price: True if the pricelist discount policy says
the price does not include the discount and there is actually a
discount applied (price < list_price), else False
"""
self.ensure_one()
quantity = self.env.context.get('quantity', add_qty)
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
product_template = self.with_context(context)
combination = combination or product_template.env['product.template.attribute.value']
if not product_id and not combination:
combination = product_template._get_first_possible_combination(parent_combination)
if product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
if product:
# We need to add the price_extra for the attributes that are not
# in the variant, typically those of type no_variant, but it is
# possible that a no_variant attribute is still in a variant if
# the type of the attribute has been changed after creation.
no_variant_attributes_price_extra = [
ptav.price_extra for ptav in combination.filtered(
lambda ptav:
ptav.price_extra and
ptav not in product.product_template_attribute_value_ids
)
]
if no_variant_attributes_price_extra:
product = product.with_context(
no_variant_attributes_price_extra=no_variant_attributes_price_extra
)
list_price = product.price_compute('list_price')[product.id]
price = product.price if pricelist else list_price
else:
product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination])
list_price = product_template.price_compute('list_price')[product_template.id]
price = product_template.price if pricelist else list_price
display_name = product_template.name
filtered_combination = combination._without_no_variant_attributes()
if filtered_combination:
display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name')))
if pricelist and pricelist.currency_id != product_template.currency_id:
list_price = product_template.currency_id._convert(
list_price, pricelist.currency_id, product_template.company_id,
fields.Date.today()
)
price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
return {
'product_id': product.id,
'product_template_id': product_template.id,
'display_name': display_name,
'price': price,
'list_price': list_price,
'has_discounted_price': has_discounted_price,
}
@api.multi
def _is_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to add to cart (potentially after configuration) if
there is at least one possible combination, or if there is no
`product.template.attribute.line` at all.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: True if it's possible to add to cart, else False
:rtype: bool
"""
self.ensure_one()
if not self.active:
return False
combination = self._get_first_possible_combination(parent_combination)
return True if combination else self._is_combination_possible(combination, parent_combination)
+69 -23
View File
@@ -1120,9 +1120,16 @@ class SaleOrderLine(models.Model):
product_uom_qty = fields.Float(string='Ordered Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0)
product_uom = fields.Many2one('uom.uom', string='Unit of Measure')
product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string='User entered custom product attribute values')
# M2M holding the values of product.attribute with create_variant field set to 'no_variant'
# It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description
# Note: If the attributes are changed on the template, some or all records
# in `product_no_variant_attribute_value_ids` will be removed here, even
# from existing or locked sales order lines. Thus this field can only be
# relied on initially, but it cannot be used to recompute anything later
# because the result might be different then.
product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants')
# Non-stored related field to allow portal user to see the image of the product he has ordered
product_image = fields.Binary('Product Image', related="product_id.image", store=False, readonly=False)
@@ -1372,11 +1379,20 @@ class SaleOrderLine(models.Model):
# TO DO: move me in master/saas-16 on sale.order
# awa: don't know if it's still the case since we need the "product_no_variant_attribute_value_ids" field now
# to be able to compute the full price
if self.product_no_variant_attribute_value_ids:
product = product.with_context(no_variant_attributes_price_extra=[
no_variant_attribute_value.price_extra or 0
for no_variant_attribute_value in self.product_no_variant_attribute_value_ids
])
# it is possible that a no_variant attribute is still in a variant if
# the type of the attribute has been changed after creation.
no_variant_attributes_price_extra = [
ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered(
lambda ptav:
ptav.price_extra and
ptav not in product.product_template_attribute_value_ids
)
]
if no_variant_attributes_price_extra:
product = product.with_context(
no_variant_attributes_price_extra=no_variant_attributes_price_extra
)
if self.order_id.pricelist_id.discount_policy == 'with_discount':
return product.with_context(pricelist=self.order_id.pricelist_id.id).price
@@ -1397,6 +1413,16 @@ class SaleOrderLine(models.Model):
if not self.product_id:
return {'domain': {'product_uom': []}}
# remove the is_custom values that don't belong to this template
for pacv in self.product_custom_attribute_value_ids:
if pacv.attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values():
self.product_custom_attribute_value_ids -= pacv
# remove the no_variant attributes that don't belong to this template
for ptav in self.product_no_variant_attribute_value_ids:
if ptav.product_attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values():
self.product_no_variant_attribute_value_ids -= ptav
vals = {}
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):
@@ -1405,7 +1431,7 @@ class SaleOrderLine(models.Model):
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id.id,
partner=self.order_id.partner_id,
quantity=vals.get('product_uom_qty') or self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1429,20 +1455,6 @@ class SaleOrderLine(models.Model):
name = self.get_sale_order_line_multiline_description_sale(product)
if self.product_custom_attribute_value_ids or self.product_no_variant_attribute_value_ids:
name += '\n'
if self.product_custom_attribute_value_ids:
for product_custom_attribute_value in self.product_custom_attribute_value_ids:
if product_custom_attribute_value.custom_value and product_custom_attribute_value.custom_value.strip():
name += '\n' + product_custom_attribute_value.attribute_value_id.name + ': ' + product_custom_attribute_value.custom_value.strip()
if self.product_no_variant_attribute_value_ids:
for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered(
lambda product_attribute_value: not product_attribute_value.is_custom
):
name += '\n' + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name
vals.update(name=name)
self._compute_tax_id()
@@ -1461,7 +1473,7 @@ class SaleOrderLine(models.Model):
if self.order_id.pricelist_id and self.order_id.partner_id:
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id.id,
partner=self.order_id.partner_id,
quantity=self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1557,7 +1569,7 @@ class SaleOrderLine(models.Model):
self.discount = 0.0
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id.id,
partner=self.order_id.partner_id,
quantity=self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1591,4 +1603,38 @@ class SaleOrderLine(models.Model):
BUT in event_sale we need to know specifically the sales order line as well as the product to generate the name:
the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line).
"""
return product.get_product_multiline_description_sale()
return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
def _get_sale_order_line_multiline_description_variants(self):
"""When using no_variant attributes or is_custom values, the product
itself is not sufficient to create the description: we need to add
information about those special attributes and values.
See note about `product_no_variant_attribute_value_ids` above the field
definition: this method is not reliable to recompute the description at
a later time, it should only be used initially.
:return: the description related to special variant attributes/values
:rtype: string
"""
if not self.product_custom_attribute_value_ids and not self.product_no_variant_attribute_value_ids:
return ""
name = "\n"
product_attribute_with_is_custom = self.product_custom_attribute_value_ids.mapped('attribute_value_id.attribute_id')
# display the no_variant attributes, except those that are also
# displayed by a custom (avoid duplicate)
for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered(
lambda ptav: ptav.attribute_id not in product_attribute_with_is_custom
):
name += "\n" + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name
# display the is_custom values
for pacv in self.product_custom_attribute_value_ids:
name += "\n" + pacv.attribute_value_id.attribute_id.name + \
': ' + pacv.attribute_value_id.name + \
': ' + (pacv.custom_value or '').strip()
return name
@@ -48,21 +48,27 @@ var ProductConfiguratorFormController = FormController.extend({
* @override
*/
_onFieldChanged: function (event) {
this._super.apply(this, arguments);
var self = this;
var product_id = event.data.changes.product_template_id.id;
// check to prevent traceback when emptying the field
if (!product_id) {
return;
}
this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled');
this._rpc({
route: '/product_configurator/configure',
params: {
product_id: event.data.changes.product_template_id.id,
product_id: product_id,
pricelist_id: this.renderer.pricelistId
}
}).then(function (configurator) {
self.renderer.renderConfigurator(configurator);
});
this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
@@ -94,7 +100,7 @@ var ProductConfiguratorFormController = FormController.extend({
false
);
productReady.done(function (productId){
productReady.done(function (productId) {
$modal.find(productSelector.join(', ')).val(productId);
var variantValues = self
@@ -13,6 +13,7 @@ var ProductConfiguratorMixin = {
'click button.js_add_cart_json': 'onClickAddCartJSON',
'change [data-attribute_exclusions]': 'onChangeVariant'
},
isSelectedVariantAllowed: true,
//--------------------------------------------------------------------------
// Public
@@ -22,7 +23,6 @@ var ProductConfiguratorMixin = {
* 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 ?
*
*/
computePriceTotal: function () {
if ($('.js_price_total').length){
@@ -56,8 +56,10 @@ var ProductConfiguratorMixin = {
$component = $(ev.currentTarget).closest('form');
} else if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){
$component = $(ev.currentTarget).closest('.oe_optional_products_modal');
} else {
} else if ($(ev.currentTarget).closest('.o_product_configurator').length > 0) {
$component = $(ev.currentTarget).closest('.o_product_configurator');
} else {
$component = $(ev.currentTarget);
}
var qty = $component.find('input[name="add_qty"]').val();
@@ -68,10 +70,10 @@ var ProductConfiguratorMixin = {
ajax.jsonRpc(this._getUri('/product_configurator/get_combination_info'), 'call', {
product_template_id: parseInt($parent.find('.product_template_id').val()),
product_id: parseInt($parent.find('.product_id').val()),
product_id: this._getProductId($parent),
combination: combination,
add_qty: parseInt(qty),
pricelist_id: this.pricelistId
pricelist_id: this.pricelistId || false,
}).then(function (combinationData) {
self._onChangeCombination(ev, $parent, combinationData);
});
@@ -291,23 +293,28 @@ var ProductConfiguratorMixin = {
*/
selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) {
var self = this;
productId = parseInt(productId);
productTemplateId = parseInt(productTemplateId);
var productReady = $.Deferred();
if (productId && productId !== '0'){
if (productId) {
productReady.resolve(productId);
} else {
var params = {
model: 'product.template',
method: 'create_product_variant',
args: [
productTemplateId,
JSON.stringify(self.getSelectedVariantValues($container))
]
product_template_id: productTemplateId,
product_template_attribute_value_ids:
JSON.stringify(self.getSelectedVariantValues($container)),
};
// Note about 12.0 compatibility: this route will not exist if
// updating the code but not restarting the server. (404)
// We don't handle that compatibility because the previous code was
// not working either: it was making an RPC that failed with any
// non-admin user anyway. To use this feature, restart the server.
var route = '/product_configurator/create_product_variant';
if (useAjax) {
productReady = ajax.jsonRpc('/web/dataset/call', 'call', params);
productReady = ajax.jsonRpc(route, 'call', params);
} else {
productReady = this._rpc(params);
productReady = this._rpc({route: route, params: params});
}
}
@@ -335,6 +342,23 @@ var ProductConfiguratorMixin = {
* @param {Array} combination the selected combination of product attribute values
*/
_checkExclusions: function ($parent, combination) {
function areCombinationsEqual(c1, c2) {
return c1.length === c2.length && _.every(c1, function (ptav) {
return c2.indexOf(ptav) > -1;
});
}
function isCombinationInList(c1, list) {
return _.some(list, function (c2) {
return areCombinationsEqual(c1, c2);
});
}
function isPtavInCombination(ptav, combination) {
return combination.indexOf(ptav) > -1;
}
var self = this;
var combinationData = $parent
.find('ul[data-attribute_exclusions]')
@@ -343,43 +367,62 @@ var ProductConfiguratorMixin = {
$parent.find('option, input, label').removeClass('css_not_available');
var disable = false;
// compatibility 12.0
var filteredCombination = combination;
if (combinationData.no_variant_product_template_attribute_value_ids !== undefined) {
var no_variants = combinationData.no_variant_product_template_attribute_value_ids;
filteredCombination = _.filter(combination, function (ptav) {
return !isPtavInCombination(ptav, no_variants);
});
}
// exclusion rules: array of ptav
// for each of them, contains array with the other ptav they exclude
if (combinationData.exclusions) {
_.each(combination, function (combinationValue){
if (combinationData.exclusions &&
combinationData.exclusions.hasOwnProperty(combinationValue)){
// check that the selected combination is in the exclusions
_.each(combinationData.exclusions[combinationValue], function (exclusion) {
if (!disable && combination.indexOf(exclusion) > -1) {
// browse all the currently selected attributes
_.each(combination, function (current_ptav) {
if (combinationData.exclusions.hasOwnProperty(current_ptav)) {
// for each exclusion of the current attribute:
_.each(combinationData.exclusions[current_ptav], function (excluded_ptav) {
// disable if it excludes any other attribute already in the combination
if (isPtavInCombination(excluded_ptav, combination)) {
disable = true;
}
self._disableInput($parent, exclusion);
// disable the excluded input (even when not already selected)
// to give a visual feedback before click
self._disableInput($parent, excluded_ptav);
});
}
});
}
if (combinationData.parent_exclusions){
_.each(combinationData.parent_exclusions, function (exclusion){
if (!disable && combination.indexOf(exclusion) > -1) {
disable = true;
}
self._disableInput($parent, exclusion);
});
// parent exclusions (tell which attributes are excluded from parent)
_.each(combinationData.parent_exclusions, function (ptav) {
if (isPtavInCombination(ptav, combination)) {
disable = true;
}
// disable the excluded input (even when not already selected)
// to give a visual feedback before click
self._disableInput($parent, ptav);
});
// archived variants
if (isCombinationInList(filteredCombination, combinationData.archived_combinations)) {
disable = true;
}
if (combinationData.archived_combinations){
_.each(combinationData.archived_combinations, function (archived_combination){
if (disable) {
return;
}
disable = _.every(archived_combination, function (attribute_value){
return combination.indexOf(attribute_value) > -1;
});
});
// if not using dynamic attributes, exclude variants that are deleted
if (filteredCombination.length && // compatibility 12.0 list view of variants
combinationData.has_dynamic_attributes === false &&
combinationData.existing_combinations !== undefined &&
!isCombinationInList(filteredCombination, combinationData.existing_combinations)
) {
disable = true;
}
this.isSelectedVariantAllowed = !disable;
$parent.toggleClass('css_not_available', disable);
$parent.find("#add_to_cart").toggleClass('disabled', disable);
$parent
@@ -388,6 +431,15 @@ var ProductConfiguratorMixin = {
.toggleClass('disabled', disable);
},
/**
* Extracted to a method to be extendable by other modules
*
* @param {$.Element} $parent
*/
_getProductId: function ($parent) {
return parseInt($parent.find('.product_id').val());
},
/**
* Will disable the input/option that refers to the passed attributeValueId.
* This is used for showing the user that some combinations are not available.
@@ -418,7 +470,11 @@ var ProductConfiguratorMixin = {
var $optional_price = $parent.find(".oe_optional:first .oe_currency_value");
$price.html(self._priceToStr(combination.price));
$default_price.html(self._priceToStr(combination.list_price));
if (combination.list_price - combination.price >= 0.01) {
// compatibility_check to remove in master
// needed for fix in 12.0 in the case of git pull and no server restart
var compatibility_check = combination.list_price - combination.price >= 0.01;
if (combination.has_discounted_price !== undefined ? combination.has_discounted_price : compatibility_check) {
$default_price
.closest('.oe_website_sale')
.addClass("discount");
@@ -428,6 +484,9 @@ var ProductConfiguratorMixin = {
.css('text-decoration', 'line-through');
$default_price.parent().removeClass('d-none');
} else {
$default_price
.closest('.oe_website_sale')
.removeClass("discount");
$optional_price.closest('.oe_optional').addClass('d-none');
$default_price.parent().addClass('d-none');
}
@@ -438,11 +497,16 @@ var ProductConfiguratorMixin = {
'.o_product_configurator'
];
self._updateProductImage(
$parent.closest(rootComponentSelectors.join(', ')),
combination.product_id,
combination.product_template_id
);
// update images only when changing product
if (combination.product_id !== this.last_product_id) {
this.last_product_id = combination.product_id;
self._updateProductImage(
$parent.closest(rootComponentSelectors.join(', ')),
combination.product_id,
combination.product_template_id,
combination.carousel
);
}
$parent
.find('.product_id')
@@ -492,47 +556,20 @@ var ProductConfiguratorMixin = {
* @param {integer} productTemplateId
*/
_updateProductImage: function ($productContainer, productId, productTemplateId) {
var $img;
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
var imageSrc = '/web/image?model={0}&id={1}&field=image'
var imageSrc = '/web/image/{0}/{1}/image'
.replace("{0}", model)
.replace("{1}", modelId);
if ($productContainer.find('#o-carousel-product').length) {
$img = $productContainer.find('img.js_variant_img');
$img.attr("src", imageSrc);
$img.parent().attr('data-oe-model', model).attr('data-oe-id', modelId)
.data('oe-model', model).data('oe-id', modelId);
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
'span.variant_image img'
];
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/{0}/{1}/image/90x90"
.replace('{0}', model)
.replace('{1}', modelId));
$('.carousel').carousel(0);
}
}
else {
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
'span.variant_image img'
];
$img = $productContainer.find(imagesSelectors.join(', '));
$img.attr('src', imageSrc);
$img.parent()
.attr('data-oe-model', model)
.attr('data-oe-id', modelId)
.data('oe-model', model)
.data('oe-id', modelId);
}
// 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;
}
var $img = $productContainer.find(imagesSelectors.join(', '));
$img.attr('src', imageSrc);
},
/**
@@ -553,11 +590,13 @@ var ProductConfiguratorMixin = {
* Website behavior is slightly different from backend so we append
* "_website" to URLs to lead to a different route
*
* TODO this should be overriden in website_sale instead.
*
* @private
* @param {string} uri The uri to adapt
*/
_getUri: function (uri) {
if (this.isWebsite){
if (this.isWebsite) {
return uri + '_website';
} else {
return uri;
@@ -72,10 +72,6 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
delete this.events['change [data-attribute_exclusions]'];
delete this.events['click button.js_add_cart_json'];
}
this._opened.then(function () {
self.triggerVariantChange(self.$el);
});
},
/**
* @override
@@ -87,7 +83,8 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
var getModalContent = ajax.jsonRpc(uri, 'call', {
product_id: self.rootProduct.product_id,
variant_values: self.rootProduct.variant_values,
pricelist_id: self.pricelistId,
pricelist_id: self.pricelistId || false,
add_qty: self.rootProduct.quantity,
kwargs: {
context: _.extend({
'quantity': self.rootProduct.quantity
@@ -126,6 +123,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
self.$modal.attr('open', true);
self.$modal.removeAttr("aria-hidden");
self.$modal.modal().appendTo(self.container);
self.$modal.focus();
self._opened.resolve();
}
});
@@ -140,10 +138,18 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
*
* @override
*/
start: function (){
this._super.apply(this, arguments);
start: function () {
var def = this._super.apply(this, arguments);
var self = this;
this.$el.find('input[name="add_qty"]').val(this.rootProduct.quantity);
return def.then(function () {
// This has to be triggered to compute the "out of stock" feature
self._opened.then(function () {
self.triggerVariantChange(self.$el);
});
});
},
//--------------------------------------------------------------------------
@@ -359,7 +365,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
ajax.jsonRpc(self._getUri("/product_configurator/optional_product_items"), 'call', {
'product_id': productId,
'pricelist_id': self.pricelistId
'pricelist_id': self.pricelistId || false,
}).then(function (addedItem) {
var $addedItem = $(addedItem);
$modal.find('tr:last').after($addedItem);
@@ -410,7 +416,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
this._removeOptionOption($modal, productTemplateId);
$('tr:last').after($parent);
$modal.find('tr:last').after($parent);
},
/**
+1 -1
View File
@@ -46,7 +46,7 @@ tour.register('sale_tour', {
content: _t("Select a product, or create a new one on the fly."),
position: "right",
run: function (actions) {
actions.text("DESK0001", this.$anchor.find("input"));
actions.text("Chair", this.$anchor.find("input"));
},
}, {
trigger: ".ui-menu-item > a",
@@ -39,6 +39,13 @@
.css_not_available_msg {
display: block;
}
.js_add,
.oe_price,
.oe_default_price,
.oe_optional {
display: none;
}
}
.css_quantity {
@@ -113,12 +120,7 @@ label.css_attribute_color.css_not_available {
}
.o_total_row {
h5 {
padding-top: 0.4rem;
}
.js_price_total {
font-size: 1.5rem;
}
font-size: 1.2rem;
}
}
@@ -140,10 +142,6 @@ label.css_attribute_color.css_not_available {
background-image: none;
}
.remove-optionnal-item, .add-optionnal-item {
padding-left: 5px;
}
.td-product_name {
word-wrap: break-word;
}
+1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_sale_product_attribute_value_config
from . import test_sale_to_invoice
from . import test_sale_order
from . import test_sale_pricelist
@@ -0,0 +1,343 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.product.tests.test_product_attribute_value_config import TestProductAttributeValueSetup
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestSaleProductAttributeValueConfig(TestProductAttributeValueSetup):
def _setup_pricelist(self):
# easy currency rate, for a simple 2 ratio in the following tests
self.computer.currency_id.rate_ids[0].rate = 1
currency_ratio = 2
currency = self.env['res.currency'].browse(1 if self.computer.currency_id.id != 1 else 2)
currency.rate_ids[0].rate = currency_ratio
discount = 10
pricelist = self.env['product.pricelist'].create({
'name': 'test pl',
'currency_id': currency.id,
'company_id': self.computer.company_id.id,
})
pricelist_item = self.env['product.pricelist.item'].create({
'min_quantity': 2,
'compute_price': 'percentage',
'percent_price': discount
})
pricelist.item_ids += pricelist_item
return (pricelist, pricelist_item, currency_ratio, 1 - discount / 100)
def test_01_is_combination_possible_archived(self):
"""The goal is to test the possibility of archived combinations.
This test could not be put into product module because there was no
field which had product_id as required and without cascade on delete.
"""
def do_test(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
variant2 = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_2)
# Create a dummy SO to prevent the variant from being deleted by
# create_variant_ids() because the variant is a related field that
# is required on the SO line
so = self.env['sale.order'].create({'partner_id': 1})
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant.id
})
# additional variant to test correct ignoring when mismatch values
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant2.id
})
variant2.active = False
# CASE: 1 not archived, 2 archived
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
# CASE: both archived combination (without no_variant)
variant.active = False
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: not archived (with no_variant)
self.computer_hdd_attribute_lines.unlink()
self.hdd_attribute.create_variant = 'no_variant'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: archived combination found (with no_variant)
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
variant.active = False
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: archived combination has different attributes (including no_variant)
self.computer_ssd_attribute_lines.unlink()
self.computer.create_variant_ids()
variant4 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant4.id
})
self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
# CASE: archived combination has different attributes (without no_variant)
self.computer_hdd_attribute_lines.unlink()
self.hdd_attribute.create_variant = 'always'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
variant5 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant5.id
})
self.assertTrue(variant4 != variant5)
self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
computer_ssd_256_before = self._get_product_template_attribute_value(self.ssd_256)
do_test(self)
# CASE: add back the removed attribute and try everything again
# It will be the same attribute but the ptal and ptav will be different!
self.computer_ssd_attribute_lines = self.env['product.template.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])],
})
self.computer.create_variant_ids()
computer_ssd_256_after = self._get_product_template_attribute_value(self.ssd_256)
self.assertTrue(computer_ssd_256_after != computer_ssd_256_before)
do_test(self)
def test_02_get_combination_info(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
# CASE: no pricelist, no currency, with existing combination, with price_extra on attributes
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
computer_variant = self.computer._get_variant_for_combination(combination)
res = self.computer._get_combination_info(combination)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant.id)
self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
self.assertEqual(res['price'], 2222)
self.assertEqual(res['list_price'], 2222)
# CASE: no combination, product given
res = self.computer._get_combination_info(self.env['product.template.attribute.value'], computer_variant.id)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant.id)
# the variant has the same name as the template
self.assertEqual(res['display_name'], "Super Computer")
self.assertEqual(res['price'], 2222)
self.assertEqual(res['list_price'], 2222)
# CASE: using pricelist, quantity rule
pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant.id)
self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: no_variant combination, it's another variant now
self.computer_ssd_attribute_lines.unlink()
self.ssd_attribute.create_variant = 'no_variant'
self._add_ssd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
computer_variant_new = self.computer._get_variant_for_combination(combination)
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant_new.id)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, but the variant already exists
self.computer_hdd_attribute_lines.unlink()
self.hdd_attribute.create_variant = 'dynamic'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant_new.id)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, no variant existing
self._add_keyboard_attribute()
self.computer.create_variant_ids()
combination += self._get_product_template_attribute_value(self.keyboard_excluded)
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], False)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
self.assertEqual(res['price'], (2222 - 5) * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
# CASE: pricelist set value to 0, no variant
pricelist_item.percent_price = 100
self.computer.invalidate_cache() # need o2m to be refetched
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], False)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
self.assertEqual(res['price'], 0)
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
def test_03_get_combination_info_discount_policy(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
pricelist.discount_policy = 'with_discount'
# CASE: no discount, setting with_discount
res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], False)
# CASE: discount, setting with_discount
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], False)
# CASE: no discount, setting without_discount
pricelist.discount_policy = 'without_discount'
res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], False)
# CASE: discount, setting without_discount
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], True)
def test_04_create_product_variant_non_dynamic(self):
"""The goal of this test is to make sure the create_product_variant does
not create variant if the type is not dynamic. It can however return a
variant if it already exists."""
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self._add_exclude(computer_ram_16, computer_hdd_1)
# CASE: variant is already created, it should return it
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
variant1 = self.computer._get_variant_for_combination(combination)
self.assertEqual(self.computer._create_product_variant(combination), variant1)
# CASE: variant does not exist, but template is non-dynamic, so it
# should not create it
Product = self.env['product.product']
variant1.unlink()
self.assertEqual(self.computer._create_product_variant(combination), Product)
def test_05_create_product_variant_dynamic(self):
"""The goal of this test is to make sure the create_product_variant does
work with dynamic. If the combination is possible, it should create it.
If it's not possible, it should not create it."""
self.computer_hdd_attribute_lines.unlink()
self.computer.create_variant_ids()
self.hdd_attribute.create_variant = 'dynamic'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
self.computer.invalidate_cache()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self._add_exclude(computer_ram_16, computer_hdd_1)
# CASE: variant does not exist, but combination is not possible
# so it should not create it
impossible_combination = computer_ssd_256 + computer_ram_16 + computer_hdd_1
Product = self.env['product.product']
self.assertEqual(self.computer._create_product_variant(impossible_combination), Product)
# CASE: the variant does not exist, and the combination is possible, so
# it should create it
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
variant = self.computer._create_product_variant(combination)
self.assertTrue(variant)
# CASE: the variant already exists, so it should return it
self.assertEqual(variant, self.computer._create_product_variant(combination))
def _add_keyboard_attribute(self):
self.keyboard_attribute = self.env['product.attribute'].create({
'name': 'Keyboard',
'sequence': 6,
'create_variant': 'dynamic',
})
self.keyboard_included = self.env['product.attribute.value'].create({
'name': 'Included',
'attribute_id': self.keyboard_attribute.id,
'sequence': 1,
})
self.keyboard_excluded = self.env['product.attribute.value'].create({
'name': 'Excluded',
'attribute_id': self.keyboard_attribute.id,
'sequence': 2,
})
self.computer_keyboard_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.keyboard_attribute.id,
'value_ids': [(6, 0, [self.keyboard_included.id, self.keyboard_excluded.id])],
})
self.computer_keyboard_attribute_lines.product_template_value_ids[0].price_extra = 5
self.computer_keyboard_attribute_lines.product_template_value_ids[1].price_extra = -5
@@ -6,31 +6,48 @@
</main>
</template>
<!-- backend -->
<template id="product_configurator_configure" name="Configure">
<div class="js_product main_product">
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
<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'"/>
<input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product_variant.id"/>
<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"}'/>
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image' % product_variant.id" class="d-block product_detail_img" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image' % product.id" class="d-block product_detail_img" alt="Product Image"/>
</div>
<div class="col-lg-1 d-inline-block"></div>
<div class="col-lg-5 d-inline-block text-left">
<t t-call="sale.variants" />
<h2 class="oe_price product_id mt-3" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="product.price"
t-options='{
"widget": "monetary",
"display_currency": to_currency
}'/>
<div t-if="product._is_add_to_cart_possible()" class="col-lg-5 d-inline-block text-left">
<t t-if="first_possible_combination" t-call="sale.variants">
<t t-set="parent_combination" t-value="None"/>
</t>
<h2>
<span t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
<span class="oe_price product_id mt-3" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
</h2>
<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"/>
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 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>
@@ -39,10 +56,14 @@
</div>
<p class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
</div>
<div t-else="" class="col-lg-5 d-inline-block text-left">
<p class="alert alert-warning">This product has no valid combination.</p>
</div>
</div>
</div>
</template>
<!-- modal: full table, currenclty selected products at top -->
<template id="product_configurator_configure_optional_products">
<table class="table table-striped table-sm">
<thead>
@@ -55,13 +76,18 @@
</thead>
<tbody>
<tr class="js_product in_cart main_product">
<t t-set="combination_info" t-value="product.product_tmpl_id._get_combination_info(combination, product.id, add_qty or 1, pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<input type="hidden" class="product_template_id" t-att-value="product.product_tmpl_id.id"/>
<input type="hidden" class="product_id" t-att-value="product.id"/>
<input type="hidden" class="product_id" t-att-value="product_variant.id"/>
<td class='td-img'>
<span t-field="product.image_medium" t-options='{"widget": "image" }'/>
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_medium' % product_variant.id" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image_medium' % product.id" alt="Product Image"/>
</td>
<td class='td-product_name'>
<strong t-field="product.display_name"/>
<strong t-esc="combination_info['display_name']"/>
<div class="text-muted small">
<div t-field="product.description_sale"/>
<div class="js_attributes"/>
@@ -74,7 +100,7 @@
<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"/>
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 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>
@@ -85,55 +111,63 @@
<td class="text-center td-price" name="price">
<ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"></ul>
<div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="variant_values" ></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"
<div t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency
}'/>
"display_currency": (pricelist or product).currency_id
}'
/>
<span class="oe_price product_id" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="product.price"
t-esc="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": to_currency
"display_currency": (pricelist or product).currency_id
}'/>
<span class="js_raw_price d-none" t-esc="product.price" />
<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">
<td colspan="4" class="text-right">
<strong>Total:</strong>
<span class="js_price_total font-weight-bold" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="product.price"
t-esc="combination_info['price'] * (add_qty or 1)"
t-options='{
"widget": "monetary",
"display_currency": to_currency
"display_currency": (pricelist or product).currency_id
}'/>
</td>
</tr>
<tr t-if="product.optional_product_ids" class="o_select_options"><td colspan="4"><h4>Available Options:</h4></td></tr>
<t t-call="sale.optional_product_items" />
<t t-call="sale.optional_product_items">
<t t-set="parent_combination" t-value="combination"/>
</t>
</tbody>
</table>
</template>
<!-- modal: optional products -->
<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 or product.has_dynamic_attributes()">
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination(parent_combination)"/>
<t t-if="product._is_add_to_cart_possible()">
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<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"/>
<input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product_variant.id"/>
<input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="combination_info['display_name']"/>
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_medium' % product_variant.id" class="variant_image" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image_medium' % product.id" class="variant_image" alt="Product Image"/>
</td>
<td class='td-product_name' colspan="2">
<div class="float-left">
<strong class="product-name" t-field="product.name"/>
<strong class="product-name" t-esc="combination_info['display_name']"/>
<div class="text-muted small" t-field="product.description_sale"/>
</div>
<div class="float-right">
@@ -141,24 +175,23 @@
</div>
</td>
<td class="text-center td-price">
<span class="text-danger oe_default_price oe_optional"
t-field="product.lst_price"
<div t-attf-class="text-danger oe_default_price oe_optional oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency
"display_currency": (pricelist or product).currency_id
}'/>
<span class="oe_price" style="white-space: nowrap;"
t-field="product.price"
<div class="oe_price" style="white-space: nowrap;"
t-esc="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": to_currency
"display_currency": (pricelist or product).currency_id
}'/>
<span class="js_raw_price d-none" t-esc="product.price" />
<span class="js_raw_price d-none" t-esc="combination_info['price']" />
<p class="css_not_available_msg alert alert-warning">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>
<a role="button" href="#" class="js_add btn btn-primary btn-sm"><i class="fa fa-shopping-cart add-optionnal-item"></i> Add to cart</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>
@@ -172,10 +205,10 @@
<template id="variants">
<t t-set="attribute_exclusions" t-value="get_attribute_exclusions(product, reference_product)"/>
<t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions(parent_combination)"/>
<ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)">
<t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id">
<!-- Attributes selection is hidden if there is only one value available and it's not a custom value -->
<t t-foreach="product._get_valid_product_template_attribute_lines()" t-as="variant_id">
<!-- Attributes selection is hidden if there is only one value available and it's not a custom value -->
<li t-att-data-attribute_id="variant_id.attribute_id.id"
t-att-data-attribute_name="variant_id.attribute_id.name"
t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}">
@@ -192,15 +225,25 @@
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom">
t-att-data-is_custom="value_id.is_custom"
t-att-selected="first_possible_combination[variant_id_index] == value_id">
<span t-field="value_id.name"/>
<span t-if="value_id.price_extra">
<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;"
<span t-if="value_id.price_extra" class="badge badge-pill badge-secondary">
<!--
price_extra is displayed as catalog price instead of
price after pricelist because it is impossible to
compute. Indeed, the pricelist rule might depend on the
selected variant, so the price_extra will be different
depending on the selected combination. The price of an
attribute is therefore variable and it's not very
accurate to display it.
-->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency or website.currency_id
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</option>
@@ -210,14 +253,13 @@
<t t-if="variant_id.attribute_id.type == 'radio'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled">
<t t-set="inc" t-value="0"/>
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<li class="form-group js_attribute_value" style="margin: 0;">
<label class="col-form-label">
<div>
<input type="radio"
t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}"
t-att-checked="'checked' if not inc else None"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
@@ -226,33 +268,32 @@
t-att-data-is_custom="value_id.is_custom" />
<div class="radio_input_value">
<span t-field="value_id.name"/>
<span class="badge badge-pill" 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;"
<span class="badge badge-pill badge-secondary" t-if="value_id.price_extra">
<!-- see note above about price_extra -->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency or website.currency_id
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</div>
</div>
</label>
</li>
<t t-set="inc" t-value="inc+1"/>
</t>
</ul>
</t>
<t t-if="variant_id.attribute_id.type == 'color'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline">
<t t-set="inc" t-value="0"/>
<li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item">
<label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}"
t-attf-class="css_attribute_color #{'active' if not inc else ''} #{'custom_value' if value_id.is_custom else ''}">
t-attf-class="css_attribute_color #{'active' if first_possible_combination[variant_id_index] == value_id else ''} #{'custom_value' if value_id.is_custom else ''}">
<input type="radio"
t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-checked="'checked' if not inc else None"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-title="value_id.name"
@@ -261,7 +302,6 @@
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom"/>
</label>
<t t-set="inc" t-value="inc+1"/>
</li>
</ul>
</t>
@@ -10,5 +10,5 @@ class SaleProductConfigurator(models.TransientModel):
product_template_id = fields.Many2one(
'product.template', string="Product",
required=True, domain=[('sale_ok', '=', True), '|', ('attribute_line_ids', '!=', False), ('optional_product_ids', '!=', False)])
required=True, domain=[('sale_ok', '=', True), '|', ('attribute_line_ids.value_ids', '!=', False), ('optional_product_ids', '!=', False)])
pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', readonly=True)
@@ -68,7 +68,7 @@ tour.register('sale_product_configurator_advanced_tour', {
trigger: ".o_sale_product_configurator_add",
run: 'click'
}, {
trigger: '.main_product strong:contains("White, Custom, PAV9, PAV5, PAV1")',
trigger: '.main_product strong:contains("Custom, White, PAV9, PAV5, PAV1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
@@ -100,7 +100,7 @@ tour.register('sale_product_configurator_advanced_tour', {
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
trigger: 'td.o_data_cell:contains("Customizable Desk (White, Custom, PAV9, PAV5, PAV1)")',
trigger: 'td.o_data_cell:contains("Customizable Desk (Custom, White, PAV9, PAV5, PAV1)")',
extra_trigger: 'div[name="order_line"]',
in_modal: false,
run: function (){} //check
@@ -0,0 +1,100 @@
odoo.define('sale.product_configurator_pricelist_tour', function (require) {
"use strict";
var tour = require('web_tour.tour');
tour.register('sale_product_configurator_pricelist_tour', {
url: "/web",
test: true,
},
[
tour.STEPS.SHOW_APPS_MENU_ITEM,
{
content: "navigate to the sale app",
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
edition: 'community'
}, {
content: "navigate to the sale app",
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
edition: 'enterprise'
}, {
content: "create a new order",
trigger: '.o_list_button_add',
extra_trigger: ".o_sale_order"
}, {
content: "search the partner",
trigger: 'div[name="partner_id"] input',
run: 'text Azure'
}, {
content: "select the partner",
trigger: 'ul.ui-autocomplete > li > a:contains(Azure)',
}, {
content: "search the pricelist",
trigger: 'div[name="pricelist_id"] input',
run: 'text Public Pricelist'
}, {
content: "select the pricelist",
trigger: 'ul.ui-autocomplete > li > a:contains(Public Pricelist)',
}, {
content: "wait for pricelist to be set",
trigger: 'div.o_with_button[name="pricelist_id"]',
run: function () {},
}, {
content: "open product configurator",
trigger: 'a:contains("Configure a product")',
}, {
content: "open product selector",
trigger: '.o_product_configurator .o_input_dropdown input',
run: 'click'
}, {
content: "select desk",
trigger: 'li a:contains("Customizable Desk")',
in_modal: false,
extra_trigger: 'ul.ui-autocomplete',
}, {
content: "check price is correct (USD)",
trigger: 'span.oe_currency_value:contains("750.00")',
run: function () {} // check price
}, {
content: "add one more",
trigger: 'button.js_add_cart_json:has(i.fa-plus)',
}, {
content: "check price for 2",
trigger: 'span.oe_currency_value:contains("600.00")',
run: function () {} // check price (pricelist has discount for 2)
}, {
content: "click add",
trigger: '.o_sale_product_configurator_add:not(.disabled)'
}, {
content: "check we are on the add modal",
trigger: '.td-product_name:contains("Customizable Desk (Steel, White)")',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
content: "add conference chair",
trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))',
run: 'click'
}, {
content: "add chair floor protection",
trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))',
run: 'click'
}, {
content: "verify configurator final price", // tax excluded
trigger: '.o_total_row .oe_currency_value:contains("1,257.00")',
}, {
content: "add to SO",
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
content: "verify SO final price excluded",
trigger: 'span[name="amount_untaxed"]:contains("1,257.00")',
}, {
content: "verify SO final price included",
trigger: 'span[name="amount_total"]:contains("1,437.00")',
}
]);
});
@@ -3,6 +3,9 @@ odoo.define('sale.product_configurator_tour', function (require) {
var tour = require('web_tour.tour');
// Note: please keep this test without pricelist for maximum coverage.
// The pricelist is tested on the other tours.
tour.register('sale_product_configurator_tour', {
url: "/web",
test: true,
@@ -15,15 +18,8 @@ tour.register('sale_product_configurator_tour', {
}, {
trigger: ".o_list_button_add",
extra_trigger: ".o_sale_order"
}, {
trigger: ".o_required_modifier[name=partner_id] input",
run: "text Couscous Magique",
}, {
trigger: ".ui-menu-item > a:contains('Couscous Magique')",
auto: true,
}, {
trigger: "a:contains('Configure a product')",
extra_trigger: ".o_field_widget[name=pricelist_id] > .o_external_button", // Wait for pricelist (onchange_partner_id)
}, {
trigger: '.o_product_configurator .o_input_dropdown input',
run: 'click'
@@ -48,7 +44,7 @@ tour.register('sale_product_configurator_tour', {
trigger: '.configurator_container span:contains("Aluminium")',
run: 'click'
}, {
trigger: 'span.oe_currency_value:contains("800")',
trigger: 'span.oe_currency_value:contains("800.40")',
run: function (){} // check updated price
}, {
trigger: 'input[data-value_name="Black"]'
@@ -78,7 +74,7 @@ tour.register('sale_product_configurator_tour', {
},
// check that 3 products were added to the SO
{
trigger: 'td.o_data_cell:contains("Customizable Desk (White, Aluminium)")',
trigger: 'td.o_data_cell:contains("Customizable Desk (Aluminium, White)")',
extra_trigger: 'div[name="order_line"]',
in_modal: false,
run: function (){}
@@ -93,15 +89,10 @@ tour.register('sale_product_configurator_tour', {
in_modal: false,
run: function (){}
}, {
trigger: '.o_readonly_modifier[name=amount_total]:contains("835")',
trigger: '.o_readonly_modifier[name=amount_total]:contains("0.00")',
in_modal: false,
edition: 'community',
run: function (){}
}, {
trigger: '.o_readonly_modifier[name=amount_total]:contains("837")',
in_modal: false,
edition: 'enterprise',
run: function (){}
}]);
}
]);
});
@@ -1,6 +1,7 @@
import odoo.tests
# Part of Odoo. See LICENSE file for full copyright and licensing details.
@odoo.tests.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
@@ -82,3 +83,43 @@ class TestUi(odoo.tests.HttpCase):
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_advanced_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_advanced_tour.ready", login="admin")
def test_04_product_configurator_pricelist(self):
"""The goal of this test is to make sure pricelist rules are correctly
applied on the backend product configurator.
Also testing B2C setting: no impact on the backend configurator.
"""
admin = self.env.ref('base.user_admin')
# Activate B2C
self.env.ref('account.group_show_line_subtotals_tax_included').users |= admin
self.env.ref('account.group_show_line_subtotals_tax_excluded').users -= admin
# Active pricelist on SO
self.env.ref('product.group_sale_pricelist').users |= admin
# Add a 15% tax on desk
tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 15})
self.env.ref('product.product_product_4_product_template').taxes_id = tax
# Remove tax from Conference Chair and Chair floor protection
self.env.ref('sale.product_product_1_product_template').taxes_id = None
self.env.ref('product.product_product_11_product_template').taxes_id = None
# Make sure pricelist rule exist
product_template = self.env.ref('product.product_product_4_product_template')
pricelist = self.env.ref('product.list0')
if not pricelist.item_ids.filtered(lambda i: i.product_tmpl_id == product_template and i.price_discount == 20):
self.env['product.pricelist.item'].create({
'base': 'list_price',
'applied_on': '1_product',
'pricelist_id': pricelist.id,
'product_tmpl_id': product_template.id,
'price_discount': 20,
'min_quantity': 2,
'compute_price': 'formula',
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_pricelist_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_pricelist_tour.ready", login="admin")
+2 -1
View File
@@ -4,6 +4,7 @@
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_test_ui.js"/>
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_advanced_test_ui.js"/>
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_pricelist_test_ui.js"/>
</xpath>
</template>
</odoo>
</odoo>
+14
View File
@@ -25,4 +25,18 @@ odoo.define("sale_stock.tour", function (require) {
}
},
});
// Check if sale_management is installed since sale_stock is adding an extra
// step to add to SO (not enough inventory)
if ('sale.product_configurator_pricelist_tour' in odoo.__DEBUG__.services) {
var steps = tour.tours.sale_product_configurator_pricelist_tour.steps;
for (var k=0; k<steps.length; k++) {
if (steps[k].content === "add to SO") {
steps.splice(k+1, 0, {
content: "click in modal on ok button",
trigger: '.modal-footer button:contains("Ok")',
});
}
}
}
});
+72 -17
View File
@@ -133,7 +133,12 @@ class Website(Website):
class WebsiteSale(ProductConfiguratorController):
def _get_compute_currency_and_context(self):
def _get_compute_currency_and_context(self, product=None):
pricelist_context, pricelist = self._get_pricelist_context()
compute_currency = self._get_compute_currency(pricelist, product)
return compute_currency, pricelist_context, pricelist
def _get_pricelist_context(self):
pricelist_context = dict(request.env.context)
pricelist = False
if not pricelist_context.get('pricelist'):
@@ -142,11 +147,12 @@ class WebsiteSale(ProductConfiguratorController):
else:
pricelist = request.env['product.pricelist'].browse(pricelist_context['pricelist'])
from_currency = request.env.user.company_id.currency_id
to_currency = pricelist.currency_id
compute_currency = lambda price: from_currency._convert(price, to_currency, request.env.user.company_id, fields.Date.today())
return pricelist_context, pricelist
return compute_currency, pricelist_context, pricelist
def _get_compute_currency(self, pricelist, product=None):
from_currency = (product or request.env.user.company_id).currency_id
to_currency = pricelist.currency_id
return lambda price: from_currency._convert(price, to_currency, (product or request.env.user).company_id, fields.Date.today())
def _get_search_order(self, post):
# OrderBy will be parsed in orm and so no direct sql injection
@@ -189,6 +195,7 @@ class WebsiteSale(ProductConfiguratorController):
'''/shop/category/<model("product.public.category", "[('website_id', 'in', (False, current_website_id))]"):category>/page/<int:page>'''
], type='http', auth="public", website=True)
def shop(self, page=0, category=None, search='', ppg=False, **post):
add_qty = int(post.get('add_qty', 1))
if category:
category = request.env['product.public.category'].search([('id', '=', int(category))], limit=1)
if not category or not category.can_access_from_current_website():
@@ -212,7 +219,7 @@ class WebsiteSale(ProductConfiguratorController):
keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, order=post.get('order'))
compute_currency, pricelist_context, pricelist = self._get_compute_currency_and_context()
pricelist_context, pricelist = self._get_pricelist_context()
request.context = dict(request.context, pricelist=pricelist.id, partner=request.env.user.partner_id)
@@ -250,10 +257,12 @@ class WebsiteSale(ProductConfiguratorController):
if products:
# get all products without limit
selected_products = Product.search(domain, limit=False)
attributes = ProductAttribute.search([('attribute_line_ids.product_tmpl_id', 'in', selected_products.ids)])
attributes = ProductAttribute.search([('attribute_line_ids.value_ids', '!=', False), ('attribute_line_ids.product_tmpl_id', 'in', selected_products.ids)])
else:
attributes = ProductAttribute.browse(attributes_ids)
compute_currency = self._get_compute_currency(pricelist, products[:1])
values = {
'search': search,
'category': category,
@@ -261,6 +270,7 @@ class WebsiteSale(ProductConfiguratorController):
'attrib_set': attrib_set,
'pager': pager,
'pricelist': pricelist,
'add_qty': add_qty,
'products': products,
'search_count': product_count, # common for all searchbox
'bins': TableCompute().process(products, ppg),
@@ -281,7 +291,9 @@ class WebsiteSale(ProductConfiguratorController):
if not product.can_access_from_current_website():
raise NotFound()
product_context = dict(request.env.context,
add_qty = int(kwargs.get('add_qty', 1))
product_context = dict(request.env.context, quantity=add_qty,
active_id=product.id,
partner=request.env.user.partner_id)
ProductCategory = request.env['product.public.category']
@@ -299,9 +311,8 @@ class WebsiteSale(ProductConfiguratorController):
pricelist = request.website.get_current_pricelist()
from_currency = request.env.user.company_id.currency_id
to_currency = pricelist.currency_id
compute_currency = lambda price: from_currency._convert(price, to_currency, request.env.user.company_id, fields.Date.today())
def compute_currency(price):
return product.currency_id._convert(price, pricelist.currency_id, product.company_id, fields.Date.today())
if not product_context.get('pricelist'):
product_context['pricelist'] = pricelist.id
@@ -312,13 +323,16 @@ class WebsiteSale(ProductConfiguratorController):
'category': category,
'pricelist': pricelist,
'attrib_values': attrib_values,
# compute_currency deprecated, get from product
'compute_currency': compute_currency,
'attrib_set': attrib_set,
'keep': keep,
'categories': categs,
'main_object': product,
'product': product,
'add_qty': add_qty,
'optional_product_ids': [p.with_context({'active_id': p.id}) for p in product.optional_product_ids],
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
}
return request.render("website_sale.product", values)
@@ -379,6 +393,7 @@ class WebsiteSale(ProductConfiguratorController):
values.update({
'website_sale_order': order,
'compute_currency': compute_currency,
'date': fields.Date.today(),
'suggested_products': [],
})
if order:
@@ -395,6 +410,7 @@ class WebsiteSale(ProductConfiguratorController):
@http.route(['/shop/cart/update'], type='http', auth="public", methods=['POST'], website=True, csrf=False)
def cart_update(self, product_id, add_qty=1, set_qty=0, **kw):
"""This route is called when adding a product to cart (no options)."""
sale_order = request.website.sale_get_order(force_create=True)
if sale_order.state != 'draft':
request.session['sale_order_id'] = None
@@ -404,20 +420,28 @@ class WebsiteSale(ProductConfiguratorController):
if kw.get('product_custom_attribute_values'):
product_custom_attribute_values = json.loads(kw.get('product_custom_attribute_values'))
no_variant_attribute_values = None
if kw.get('no_variant_attribute_values'):
no_variant_attribute_values = json.loads(kw.get('no_variant_attribute_values'))
sale_order._cart_update(
product_id=int(product_id),
add_qty=add_qty,
set_qty=set_qty,
product_custom_attribute_values=product_custom_attribute_values
product_custom_attribute_values=product_custom_attribute_values,
no_variant_attribute_values=no_variant_attribute_values
)
return request.redirect("/shop/cart")
@http.route(['/shop/cart/update_json'], type='json', auth="public", methods=['POST'], website=True, csrf=False)
def cart_update_json(self, product_id, line_id=None, add_qty=None, set_qty=None, display=True):
"""This route is called when changing quantity from the cart or adding
a product from the wishlist."""
order = request.website.sale_get_order(force_create=1)
if order.state != 'draft':
request.website.sale_reset()
return {}
value = order._cart_update(product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty)
if not order.cart_quantity:
@@ -434,14 +458,16 @@ class WebsiteSale(ProductConfiguratorController):
value['website_sale.cart_lines'] = request.env['ir.ui.view'].render_template("website_sale.cart_lines", {
'website_sale_order': order,
# compute_currency deprecated (not used in view)
'compute_currency': lambda price: from_currency._convert(
price, to_currency, request.env.user.company_id, fields.Date.today()),
price, to_currency, order.company_id, fields.Date.today()),
'date': fields.Date.today(),
'suggested_products': order._cart_accessories()
})
value['website_sale.short_cart_summary'] = request.env['ir.ui.view'].render_template("website_sale.short_cart_summary", {
'website_sale_order': order,
'compute_currency': lambda price: from_currency._convert(
price, to_currency, request.env.user.company_id, fields.Date.today()),
price, to_currency, order.company_id, fields.Date.today()),
})
return value
@@ -1097,6 +1123,7 @@ class WebsiteSale(ProductConfiguratorController):
@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):
"""This route is called when submitting the optional product modal."""
if lang:
request.website = request.website.with_context(lang=lang)
@@ -1160,14 +1187,42 @@ class WebsiteSale(ProductConfiguratorController):
return None
# ------------------------------------------------------
# Product Configurator
# ------------------------------------------------------
@http.route(auth="public")
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
"""Override because on the website the public user must access it."""
return super(WebsiteSale, self).create_product_variant(product_template_id, product_template_attribute_value_ids, **kwargs)
@http.route(['/product_configurator/show_optional_products_website'], type='json', auth="public", methods=['POST'], website=True)
def show_optional_products_website(self, product_id, variant_values, **kw):
return self._show_optional_products(product_id, variant_values, request.website.get_current_pricelist(), True, **kw)
"""Special route to use website logic in get_combination_info override.
This route is called in JS by appending _website to the base route.
"""
kw.pop('pricelist_id')
return self.show_optional_products(product_id, variant_values, request.website.get_current_pricelist(), **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)
"""Special route to use website logic in get_combination_info override.
This route is called in JS by appending _website to the base route.
"""
kw.pop('pricelist_id')
return self.optional_product_items(product_id, request.website.get_current_pricelist(), **kw)
@http.route(['/product_configurator/get_combination_info_website'], type='json', auth="public", methods=['POST'], website=True)
def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
return self._get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist())
"""Special route to use website logic in get_combination_info override.
This route is called in JS by appending _website to the base route.
"""
kw.pop('pricelist_id')
res = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw)
if request.env.ref('website_sale.shop_product_carousel', raise_if_not_found=False): # IF for compatibility 12.0
res.update(carousel=request.env['ir.ui.view'].render_template('website_sale.shop_product_carousel', values={
'product': request.env['product.template'].browse(res['product_template_id']),
'product_variant': request.env['product.product'].browse(res['product_id']),
}))
return res
+12
View File
@@ -1129,6 +1129,18 @@ msgstr ""
msgid "It is forbidden to modify a sales order which is not in draft status."
msgstr ""
#. module: website_sale
#: code:addons/website_sale/models/sale_order.py
#, python-format
msgid "The given product does not exist therefore it cannot be added to cart."
msgstr ""
#. module: website_sale
#: code:addons/website_sale/models/sale_order.py
#, python-format
msgid "The given combination does not exist therefore it cannot be added to cart."
msgstr ""
#. module: website_sale
#: selection:res.company,website_sale_onboarding_payment_acquirer_state:0
msgid "Just done"
+164 -35
View File
@@ -2,10 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools, _
from odoo.addons import decimal_precision as dp
from odoo.tools import pycompat
from odoo.tools.translate import html_translate
from odoo.tools import float_compare
class ProductStyle(models.Model):
@@ -127,19 +124,167 @@ class ProductTemplate(models.Model):
"Shop > Customize and enable 'E-commerce categories' to view all e-commerce categories.")
product_image_ids = fields.One2many('product.image', 'product_tmpl_id', string='Images')
# website_price deprecated, directly use _get_combination_info instead
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
# website_public_price deprecated, directly use _get_combination_info instead
website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price'))
# website_price_difference deprecated, directly use _get_combination_info instead
website_price_difference = fields.Boolean('Website price difference', compute='_website_price')
def _website_price(self):
# First filter out the ones that have no variant:
# This makes sure that every template below has a corresponding product in the zipped result.
self = self.filtered('product_variant_id')
# use mapped who returns a recordset with only itself to prefetch (and don't prefetch every product_variant_ids)
for template, product in pycompat.izip(self, self.mapped('product_variant_id')):
template.website_price = product.website_price
template.website_public_price = product.website_public_price
template.website_price_difference = product.website_price_difference
current_website = self.env['website'].get_current_website()
for template in self.with_context(website_id=current_website.id):
res = template._get_combination_info(template._get_first_possible_combination())
template.website_price = res.get('price')
template.website_public_price = res.get('list_price')
template.website_price_difference = res.get('has_discounted_price')
@api.multi
def _has_no_variant_attributes(self):
"""Return whether this `product.template` has at least one no_variant
attribute.
:return: True if at least one no_variant attribute, False otherwise
:rtype: bool
"""
self.ensure_one()
return any(a.create_variant == 'no_variant' for a in self._get_valid_product_attributes())
@api.multi
def _has_is_custom_values(self):
self.ensure_one()
"""Return whether this `product.template` has at least one is_custom
attribute value.
:return: True if at least one is_custom attribute value, False otherwise
:rtype: bool
"""
return any(v.is_custom for v in self._get_valid_product_attribute_values())
@api.multi
def _is_quick_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to quickly add to cart if there's no optional product
and there's only one possible combination, and no attribute is set
to dynamic or no_variant, and no value is set to is_custom.
:param parent_combination: combination from which `self` is an
optional or accessory product
:type parent_combination: recordset `product.template.attribute.value`
:return: True if it's possible to quickly add to cart, else False
:rtype: bool
"""
self.ensure_one()
if not self._is_add_to_cart_possible(parent_combination):
return False
if len(self._get_possible_variants(parent_combination)) != 1:
return False
if self._has_no_variant_attributes():
return False
if self.has_dynamic_attributes():
return False
if self._has_is_custom_values():
return False
if self.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(self._get_first_possible_combination())):
return False
return True
@api.multi
def _get_possible_variants_sorted(self, parent_combination=None):
"""Return the sorted recordset of variants that are possible.
The order is based on the order of the attributes and their values.
See `_get_possible_variants` for the limitations of this method with
dynamic or no_variant attributes.
:param parent_combination: combination from which `self` is an
optional or accessory product
:type parent_combination: recordset `product.template.attribute.value`
:return: the sorted variants that are possible
:rtype: recordset of `product.product`
"""
self.ensure_one()
def _sort_key_attribute_value(value):
# if you change this order, keep it in sync with _order from `product.attribute`
return (value.attribute_id.sequence, value.attribute_id.id)
def _sort_key_variant(variant):
"""
We assume all variants will have the same attributes, with only one value for each.
- first level sort: same as "product.attribute"._order
- second level sort: same as "product.attribute.value"._order
"""
keys = []
for attribute in variant.attribute_value_ids.sorted(_sort_key_attribute_value):
# if you change this order, keep it in sync with _order from `product.attribute.value`
keys.append(attribute.sequence)
keys.append(attribute.id)
return keys
return self._get_possible_variants(parent_combination).sorted(_sort_key_variant)
@api.multi
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
"""Override for website, where we want to:
- take the website pricelist if no pricelist is set
- apply the b2b/b2c setting to the result
This will work when adding website_id to the context, which is done
automatically when called from routes with website=True.
"""
self.ensure_one()
current_website = False
if self.env.context.get('website_id'):
current_website = self.env['website'].get_current_website()
if not pricelist:
pricelist = current_website.get_current_pricelist()
combination_info = super(ProductTemplate, self)._get_combination_info(combination, product_id, add_qty, pricelist, parent_combination)
if self.env.context.get('website_id'):
partner = self.env.user.partner_id
company_id = current_website.company_id
product = self.env['product.product'].browse(combination_info['product_id']) or self
tax_display = self.env.user.has_group('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included'
taxes = partner.property_account_position_id.map_tax(product.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), product, partner)
# The list_price is always the price of one.
quantity_1 = 1
price = taxes.compute_all(combination_info['price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
if pricelist.discount_policy == 'without_discount':
list_price = taxes.compute_all(combination_info['list_price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
else:
list_price = price
has_discounted_price = pricelist.currency_id.compare_amounts(list_price, price) == 1
combination_info.update(
price=price,
list_price=list_price,
has_discounted_price=has_discounted_price,
)
return combination_info
@api.multi
def _create_first_product_variant(self, log_warning=False):
"""Create if necessary and possible and return the first product
variant for this template.
:param log_warning: whether a warning should be logged on fail
:type log_warning: bool
:return: the first product variant or none
:rtype: recordset of `product.product`
"""
return self._create_product_variant(self._get_first_possible_combination(), log_warning)
def _default_website_sequence(self):
self._cr.execute("SELECT MIN(website_sequence) FROM %s" % self._table)
@@ -187,35 +332,19 @@ class Product(models.Model):
website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False)
# website_price deprecated, directly use _get_combination_info instead
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
# website_public_price deprecated, directly use _get_combination_info instead
website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price'))
# website_price_difference deprecated, directly use _get_combination_info instead
website_price_difference = fields.Boolean('Website price difference', compute='_website_price')
def _website_price(self):
qty = self._context.get('quantity', 1.0)
partner = self.env.user.partner_id
current_website = self.env['website'].get_current_website()
pricelist = current_website.get_current_pricelist()
company_id = current_website.company_id
context = dict(self._context, pricelist=pricelist.id, partner=partner)
self2 = self.with_context(context) if self._context != context else self
ret = self.env.user.has_group('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included'
for p, p2 in pycompat.izip(self, self2):
taxes = partner.property_account_position_id.map_tax(p.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), p, partner)
p.website_price = taxes.compute_all(p2.price, pricelist.currency_id, quantity=qty, product=p2, partner=partner)[ret]
# We must convert the price_without_pricelist in the same currency than the
# website_price, otherwise the comparison doesn't make sense. Moreover, we show a price
# difference only if the website price is lower
price_without_pricelist = p.list_price
if company_id.currency_id != pricelist.currency_id:
date = fields.Date.today()
price_without_pricelist = company_id.currency_id._convert(price_without_pricelist, pricelist.currency_id, company_id, date)
price_without_pricelist = taxes.compute_all(price_without_pricelist, pricelist.currency_id)[ret]
p.website_price_difference = True if float_compare(price_without_pricelist, p.website_price, precision_rounding=pricelist.currency_id.rounding) > 0 else False
p.website_public_price = taxes.compute_all(p2.lst_price, quantity=qty, product=p2, partner=partner)[ret]
for product in self:
res = product._get_combination_info_variant()
product.website_price = res.get('price')
product.website_public_price = res.get('list_price')
product.website_price_difference = res.get('has_discounted_price')
@api.multi
def website_publish_button(self):
+79 -26
View File
@@ -66,11 +66,17 @@ class SaleOrder(models.Model):
@api.multi
def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs):
"""Find the cart line matching the given parameters.
If a product_id is given, the line will match the product only if the
line also has the same special attributes: `no_variant` attributes and
`is_custom` values.
"""
self.ensure_one()
product = self.env['product.product'].browse(product_id)
# split lines with the same product if it has untracked attributes
if product and product.mapped('attribute_line_ids').filtered(lambda r: not r.attribute_id.create_variant == 'always') and not line_id:
if product and (product.product_tmpl_id.has_dynamic_attributes() or product.product_tmpl_id._has_no_variant_attributes()) and not line_id:
return self.env['sale.order.line']
domain = [('order_id', '=', self.id), ('product_id', '=', product_id)]
@@ -101,7 +107,7 @@ class SaleOrder(models.Model):
product_context = dict(self.env.context)
product_context.setdefault('lang', order.partner_id.lang)
product_context.update({
'partner': order.partner_id.id,
'partner': order.partner_id,
'quantity': qty,
'date': order.date_order,
'pricelist': order.pricelist_id.id,
@@ -123,6 +129,7 @@ class SaleOrder(models.Model):
@api.multi
def _get_line_description(self, order_id, product_id, no_variant_attribute_values=None, custom_values=None):
"""Deprecated, use `get_sale_order_line_multiline_description_sale`"""
order = self.sudo().browse(order_id)
product_context = dict(self.env.context)
product_context.setdefault('lang', order.partner_id.lang)
@@ -146,7 +153,9 @@ class SaleOrder(models.Model):
def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs):
""" Add or set product quantity, add_qty can be negative """
self.ensure_one()
SaleOrderLineSudo = self.env['sale.order.line'].sudo()
product_context = dict(self.env.context)
product_context.setdefault('lang', self.sudo().partner_id.lang)
SaleOrderLineSudo = self.env['sale.order.line'].sudo().with_context(product_context)
try:
if add_qty:
@@ -164,28 +173,73 @@ class SaleOrder(models.Model):
request.session['sale_order_id'] = None
raise UserError(_('It is forbidden to modify a sales order which is not in draft status.'))
if line_id is not False:
order_lines = self._cart_find_product_line(product_id, line_id, **kwargs)
order_line = order_lines and order_lines[0]
order_line = self._cart_find_product_line(product_id, line_id, **kwargs)[:1]
# Create line if no line with product_id can be located
if not order_line:
# change lang to get correct name of attributes/values
product = self.env['product.product'].with_context(product_context).browse(int(product_id))
if not product:
raise UserError(_("The given product does not exist therefore it cannot be added to cart."))
no_variant_attribute_values = kwargs.get('no_variant_attribute_values') or []
received_no_variant_values = product.env['product.template.attribute.value'].browse([int(ptav['value']) for ptav in no_variant_attribute_values])
received_combination = product.product_template_attribute_value_ids | received_no_variant_values
product_template = product.product_tmpl_id
# handle all cases where incorrect or incomplete data are received
combination = product_template._get_closest_possible_combination(received_combination)
# get or create (if dynamic) the correct variant
product = product_template._create_product_variant(combination)
if not product:
raise UserError(_("The given combination does not exist therefore it cannot be added to cart."))
product_id = product.id
values = self._website_product_id_change(self.id, product_id, qty=1)
custom_values = kwargs.get('product_custom_attribute_values')
# add no_variant attributes that were not received
for ptav in combination.filtered(lambda ptav: ptav.attribute_id.create_variant == 'no_variant' and ptav not in received_no_variant_values):
no_variant_attribute_values.append({
'value': ptav.id,
'attribute_name': ptav.attribute_id.name,
'attribute_value_name': ptav.name,
})
# save no_variant attributes values
if no_variant_attribute_values:
values['product_no_variant_attribute_value_ids'] = [
(6, 0, [int(attribute['value']) for attribute in no_variant_attribute_values])
]
# add is_custom attribute values that were not received
custom_values = kwargs.get('product_custom_attribute_values') or []
received_custom_values = product.env['product.attribute.value'].browse([int(ptav['attribute_value_id']) for ptav in custom_values])
for ptav in combination.filtered(lambda ptav: ptav.is_custom and ptav.product_attribute_value_id not in received_custom_values):
custom_values.append({
'attribute_value_id': ptav.product_attribute_value_id.id,
'attribute_value_name': ptav.name,
'custom_value': '',
})
# save is_custom attributes values
if custom_values:
values['product_custom_attribute_value_ids'] = [(0, 0, {
'attribute_value_id': custom_value['attribute_value_id'],
'custom_value': custom_value['custom_value']
}) for custom_value in custom_values]
no_variant_attribute_values = kwargs.get('no_variant_attribute_values')
if no_variant_attribute_values:
values['product_no_variant_attribute_value_ids'] = [
(6, 0, [int(attribute['value']) for attribute in no_variant_attribute_values])
]
values['name'] = self._get_line_description(self.id, product_id, no_variant_attribute_values=no_variant_attribute_values, custom_values=custom_values)
# create the line
order_line = SaleOrderLineSudo.create(values)
# Generate the description with everything. This is done after
# creating because the following related fields have to be set:
# - product_no_variant_attribute_value_ids
# - product_custom_attribute_value_ids
order_line.name = order_line.get_sale_order_line_multiline_description_sale(product)
try:
order_line._compute_tax_id()
@@ -206,13 +260,12 @@ class SaleOrder(models.Model):
order_line.unlink()
else:
# update line
values = self._website_product_id_change(self.id, product_id, qty=quantity)
no_variant_attributes_price_extra = [ptav.price_extra for ptav in order_line.product_no_variant_attribute_value_ids]
values = self.with_context(no_variant_attributes_price_extra=no_variant_attributes_price_extra)._website_product_id_change(self.id, product_id, qty=quantity)
if self.pricelist_id.discount_policy == 'with_discount' and not self.env.context.get('fixed_price'):
order = self.sudo().browse(self.id)
product_context = dict(self.env.context)
product_context.setdefault('lang', order.partner_id.lang)
product_context.update({
'partner': order.partner_id.id,
'partner': order.partner_id,
'quantity': quantity,
'date': order.date_order,
'pricelist': order.pricelist_id.id,
@@ -245,15 +298,15 @@ class SaleOrder(models.Model):
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)
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)
accessory_products = self.env['product.product']
for line in order.website_order_line.filtered(lambda l: l.product_id):
combination = line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids
accessory_products |= line.product_id.accessory_product_ids.filtered(lambda product:
product.website_published and
product not in products and
product._is_variant_possible(parent_combination=combination)
)
return random.sample(accessory_products, len(accessory_products))
@@ -303,4 +356,4 @@ class SaleOrderLine(models.Model):
record.name_short = record.product_id.with_context(display_default_code=False).display_name
def get_description_following_lines(self):
return self.name.splitlines()[1:]
return self.name.splitlines()[1:]
+131 -37
View File
@@ -151,6 +151,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
'change select[name="country_id"]': '_onChangeCountry',
'change #shipping_use_same': '_onChangeShippingUseSame',
'click .toggle_summary': '_onToggleSummary',
'click input.js_product_change': 'onChangeVariant',
},
/**
@@ -177,6 +178,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
$('input.js_product_change', product).first().trigger('change');
});
// This has to be triggered to compute the "out of stock" feature
this.triggerVariantChange(this.$el);
this.$('select[name="country_id"]').change();
@@ -189,46 +191,24 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
}
});
// Do not activate image zoom for mobile devices, since it might prevent users from scrolling the page
if (!config.device.isMobile) {
var autoZoom = $('.ecom-zoomable').data('ecom-zoom-auto') || false,
factorZoom = parseFloat($('.ecom-zoomable').data('ecom-zoom-factor')) || 1.5,
attach = '#o-carousel-product';
_.each($('.ecom-zoomable img[data-zoom]'), function (el) {
onImageLoaded(el, function () {
var $img = $(el);
if (!_.str.endsWith(el.src, el.dataset.zoomImage) || // if zoom-img != img
el.naturalWidth >= $(attach).width() * factorZoom || el.naturalHeight >= $(attach).height() * factorZoom) {
$img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach});
$img.attr('data-zoom', 1); // add cursor (if previously removed)
} else {
$img.removeAttr('data-zoom'); // remove cursor
// remove zooming but keep the attribute because
// it can potentially be set back
$img.attr('data-zoom-image', '');
}
});
});
}
function onImageLoaded(img, callback) {
// On Chrome the load event already happened at this point so we
// have to rely on complete. On Firefox it seems that the event is
// always triggered after this so we can rely on it.
//
// However on the "complete" case we still want to keep listening to
// the event because if the image is changed later (eg. product
// configurator) a new load event will be triggered (both browsers).
$(img).on('load', function () {
callback();
});
if (img.complete) {
callback();
}
}
this._startZoom();
return def;
},
/**
* The selector is different when using list view of variants.
*
* @override
*/
getSelectedVariantValues: function ($container) {
var combination = $container.find('input.js_product_change:checked')
.data('combination');
if (combination) {
return JSON.parse(combination);
}
return ProductConfiguratorMixin.getSelectedVariantValues.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
@@ -340,6 +320,120 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
}
});
},
/**
* This is overridden to handle the "List View of Variants" of the web shop.
* That feature allows directly selecting the variant from a list instead of selecting the
* attribute values.
*
* Since the layout is completely different, we need to fetch the product_id directly
* from the selected variant.
*
* @override
*/
_getProductId: function ($parent) {
if ($parent.find('input.js_product_change').length !== 0) {
return parseInt($parent.find('input.js_product_change:checked').val());
}
else {
return ProductConfiguratorMixin._getProductId.apply(this, arguments);
}
},
/**
* @private
*/
_startZoom: function () {
// Do not activate image zoom for mobile devices, since it might prevent users from scrolling the page
if (!config.device.isMobile) {
var autoZoom = $('.ecom-zoomable').data('ecom-zoom-auto') || false,
factorZoom = parseFloat($('.ecom-zoomable').data('ecom-zoom-factor')) || 1.5,
attach = '#o-carousel-product';
_.each($('.ecom-zoomable img[data-zoom]'), function (el) {
onImageLoaded(el, function () {
var $img = $(el);
if (!_.str.endsWith(el.src, el.dataset.zoomImage) || // if zoom-img != img
el.naturalWidth >= $(attach).width() * factorZoom || el.naturalHeight >= $(attach).height() * factorZoom) {
$img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach});
$img.attr('data-zoom', 1); // add cursor (if previously removed)
} else {
$img.removeAttr('data-zoom'); // remove cursor
// remove zooming but keep the attribute because
// it can potentially be set back
$img.attr('data-zoom-image', '');
}
});
});
}
function onImageLoaded(img, callback) {
// On Chrome the load event already happened at this point so we
// have to rely on complete. On Firefox it seems that the event is
// always triggered after this so we can rely on it.
//
// However on the "complete" case we still want to keep listening to
// the event because if the image is changed later (eg. product
// configurator) a new load event will be triggered (both browsers).
$(img).on('load', function () {
callback();
});
if (img.complete) {
callback();
}
}
},
/**
* On website, we display a carousel instead of only one image
*
* @override
* @private
*/
_updateProductImage: function ($productContainer, productId, productTemplateId, new_carousel) {
var $img;
var $carousel = $productContainer.find('#o-carousel-product');
if (new_carousel) {
// When using the web editor, don't reload this or the images won't
// be able to be edited depending on if this is done loading before
// or after the editor is ready.
if (window.location.search.indexOf('enable_editor') === -1) {
var $new_carousel = $(new_carousel);
$carousel.after($new_carousel);
$carousel.remove();
$carousel = $new_carousel;
$carousel.carousel(0);
this._startZoom();
// fix issue with carousel height
this.trigger_up('animation_start_demand', {$target: $carousel});
}
}
else { // compatibility 12.0
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
var imageSrc = '/web/image/{0}/{1}/image'
.replace("{0}", model)
.replace("{1}", modelId);
$img = $productContainer.find('img.js_variant_img');
$img.attr("src", imageSrc);
$img.parent().attr('data-oe-model', model).attr('data-oe-id', modelId)
.data('oe-model', model).data('oe-id', modelId);
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/{0}/{1}/image/90x90"
.replace('{0}', model)
.replace('{1}', modelId));
$('.carousel').carousel(0);
}
// 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;
}
}
$carousel.toggleClass('css_not_available', !this.isSelectedVariantAllowed);
},
//--------------------------------------------------------------------------
// Handlers
@@ -24,6 +24,7 @@ sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend(ProductConfig
this.isWebsite = true;
delete this.events['change [data-attribute_exclusions]'];
delete this.events['click input.js_product_change'];
delete this.events['change input.js_quantity'];
delete this.events['click button.js_add_cart_json'];
},
@@ -68,7 +69,7 @@ sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend(ProductConfig
false
);
return productReady.done(function (productId){
return productReady.done(function (productId) {
$form.find(productSelector.join(', ')).val(productId);
self.rootProduct = {
@@ -44,8 +44,8 @@ tour.register('shop_buy_product', {
},
{
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")',
extra_trigger: '#wrap:not(:has(#cart_products:contains("Storage Box")))',
trigger: '.oe_cart:has(tr:contains("Storage Box")) a:contains("Add to Cart")',
},
{
content: "add one more",
@@ -53,7 +53,7 @@ tour.register('shop_buy_product', {
trigger: '#cart_products tr:contains("Steel") a.js_add_cart_json:eq(1)',
},
{
content: "remove Headphones",
content: "remove Storage Box",
extra_trigger: '#cart_products tr:contains("Steel") input.js_quantity:propValue(2)',
trigger: '#cart_products tr:contains("Storage Box") a.js_add_cart_json:first',
},
@@ -9,7 +9,7 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
test: true,
wait_for: base.ready()
}, [{
trigger: 'img[src*="/product.template/9"]',
trigger: 'img[src*="/product.product/10"]',
run: 'click'
}, {
trigger: 'li.js_attribute_value span:contains(Custom)',
@@ -39,7 +39,7 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
trigger: '.oe_price span:contains(22.9)',
trigger: '.oe_price span:contains(22.90)',
run: function (){}, // check
}, {
trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair)) .js_add',
@@ -1,6 +1,6 @@
odoo.define('website_sale.tour_shop_customize', function (require) {
'use strict';
var tour = require("web_tour.tour");
var base = require("web_editor.base");
@@ -19,7 +19,7 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
trigger: "#customize-menu a:contains(Product Attribute's Filters)",
},
{
content: "select product attribute memory Steel",
content: "select product attribute Steel",
extra_trigger: 'body:not(:has(#customize-menu:visible .dropdown-menu:visible))',
trigger: 'form.js_attributes label:contains(Steel) input:not(:checked)',
},
@@ -30,8 +30,8 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
},
{
content: "select product",
extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(2)))',
trigger: '.oe_product_cart a:contains("Customizable Desk")',
extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(3)))',
trigger: '.oe_product_cart a:contains("Test Product")',
},
{
content: "open customize menu",
@@ -79,24 +79,18 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
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: "check price is 750",
trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
run: function () {},
},
{
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",
content: "switch to another 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
trigger: ".product_price .oe_price .oe_currency_value:containsExact(800.40)",
run: function () {},
},
{
content: "open customize menu",
@@ -112,81 +106,35 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
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 price is 750",
trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
run: function () {},
},
{
content: "switch to Aluminium variant",
trigger: ".js_product label:contains('Aluminium')",
trigger: '.js_product input[data-value_name="Aluminium"]',
},
{
content: "verify that price has changed when changing variant",
trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))",
trigger: ".product_price .oe_price .oe_currency_value:containsExact(800.40)",
run: function () {}, // it's a check
},
{
content: "switch back to Steel variant",
trigger: ".js_product label:contains('Steel')",
},
{
content: "check price is 750",
trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
run: function () {},
},
{
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))',
trigger: '.my_cart_quantity:containsExact(1),.o_extra_menu_items .fa-plus',
run: function () {}, // it's a check
},
{
@@ -211,6 +159,5 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
},
]
);
});
@@ -0,0 +1,51 @@
odoo.define('website_sale.tour_shop_deleted_archived_variants', function (require) {
'use strict';
var tour = require('web_tour.tour');
var base = require('web_editor.base');
// This tour relies on a data created from the python test.
tour.register('tour_shop_deleted_archived_variants', {
test: true,
url: '/shop?search=Test Product',
wait_for: base.ready(),
},
[
{
content: "select Test Product",
trigger: '.oe_product_cart a:containsExact("Test Product")',
},
{
content: "check price (3rd variant)",
trigger: '.oe_currency_value:contains("31.00")'
},
{
content: "click on the second variant",
trigger: 'input[data-attribute_name="My Attribute"][data-value_name="My Value 2"]',
},
{
content: "check combination is not possible",
trigger: '.js_main_product.css_not_available .css_not_available_msg:contains("This combination does not exist.")'
},
{
content: "click on the 3rd variant to reset the warning",
trigger: 'input[data-attribute_name="My Attribute"][data-value_name="My Value 3"]',
},
{
content: "check price (3rd variant)",
trigger: '.oe_currency_value:contains("31.00")'
},
{
content: "click on the first variant",
trigger: 'input[data-attribute_name="My Attribute"][data-value_name="My Value 1"]',
},
{
content: "check combination is not possible",
trigger: '.js_main_product.css_not_available .css_not_available_msg:contains("This combination does not exist.")'
},
{
content: "check add to cart not possible",
trigger: '#add_to_cart.disabled',
}
]);
});
@@ -0,0 +1,36 @@
odoo.define('website_sale.tour_shop_dynamic_variants', function (require) {
'use strict';
var tour = require('web_tour.tour');
var base = require('web_editor.base');
// This tour relies on a data created from the python test.
tour.register('tour_shop_dynamic_variants', {
test: true,
url: '/shop?search=Dynamic Product',
wait_for: base.ready(),
},
[
{
content: "select Dynamic Product",
trigger: '.oe_product_cart a:containsExact("Dynamic Product")',
},
{
content: "click on the second variant",
trigger: 'input[data-attribute_name="Dynamic Attribute"][data-value_name="Dynamic Value 2"]',
},
{
content: "wait for variant to be loaded",
trigger: '.oe_price .oe_currency_value:contains("0.00")',
run: function () {},
},
{
content: "click add to cart",
trigger: '#add_to_cart',
},
{
content: "check the variant is in the cart",
trigger: 'td.td-product_name:contains(Dynamic Product (Dynamic Value 2))',
},
]);
});
@@ -0,0 +1,39 @@
odoo.define('website_sale.tour_shop_no_variant_attribute', function (require) {
'use strict';
var tour = require('web_tour.tour');
var base = require('web_editor.base');
// This tour relies on a data created from the python test.
tour.register('tour_shop_no_variant_attribute', {
test: true,
url: '/shop?search=Test Product',
wait_for: base.ready(),
},
[
{
content: "select Test Product",
trigger: '.oe_product_cart a:containsExact("Test Product")',
},
{
content: "check price",
trigger: '.oe_currency_value:contains("1.00")',
run: function () {},
},
{
content: "add to cart",
trigger: 'a:contains(Add to Cart)',
},
{
content: "check no_variant value is present",
trigger: '.td-product_name:contains(No Variant Attribute: No Variant Value)',
extra_trigger: '#cart_products',
run: function () {},
},
{
content: "check price is correct",
trigger: '.td-price:contains(11.0)',
run: function () {},
},
]);
});
@@ -612,6 +612,11 @@ a.no-decoration {
}
#o-carousel-product {
&.css_not_available {
opacity: 0.2;
}
.carousel-inner div.item, .carousel-control-prev, .carousel-control-next {
height: 400px;
}
+2
View File
@@ -1,3 +1,5 @@
from . import test_customize
from . import test_sale_process
from . import test_website_sale_pricelist
from . import test_website_sale_product_attribute_value_config
from . import test_website_sale_zoom
+198
View File
@@ -2,8 +2,206 @@
import odoo.tests
@odoo.tests.common.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
def test_01_admin_shop_customize_tour(self):
# create a template
product_template = self.env['product.template'].create({
'name': 'Test Product',
'website_published': True,
'list_price': 750,
})
product_attribute = self.env.ref('product.product_attribute_1')
product_attribute_value_1 = self.env.ref('product.product_attribute_value_1')
product_attribute_value_2 = self.env.ref('product.product_attribute_value_2')
# set attribute and attribute values on the template
self.env['product.template.attribute.line'].create([{
'attribute_id': product_attribute.id,
'product_tmpl_id': product_template.id,
'value_ids': [(6, 0, [product_attribute_value_1.id, product_attribute_value_2.id])]
}])
# set a different price on the variants to differentiate them
product_template_attribute_values = self.env['product.template.attribute.value'] \
.search([('product_tmpl_id', '=', product_template.id)])
for ptav in product_template_attribute_values:
if ptav.name == "Steel":
ptav.price_extra = 0
else:
ptav.price_extra = 50.4
product_template.create_variant_ids()
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_customize')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_customize.ready", login="admin")
def test_02_admin_shop_custom_attribute_value_tour(self):
# Make sure pricelist rule exist
product_template = self.env.ref('product.product_product_4_product_template')
# fix runbot, sometimes one pricelist is chosen, sometimes the other...
pricelists = self.env['website'].get_current_website().get_current_pricelist() | self.env.ref('product.list0')
for pricelist in pricelists:
if not pricelist.item_ids.filtered(lambda i: i.product_tmpl_id == product_template and i.price_discount == 20):
self.env['product.pricelist.item'].create({
'base': 'list_price',
'applied_on': '1_product',
'pricelist_id': pricelist.id,
'product_tmpl_id': product_template.id,
'price_discount': 20,
'min_quantity': 2,
'compute_price': 'formula',
})
pricelist.discount_policy = 'without_discount'
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_custom_attribute_value')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_custom_attribute_value.ready", login="admin")
def test_03_public_tour_shop_dynamic_variants(self):
""" The goal of this test is to make sure product variants with dynamic
attributes can be created by the public user (when being added to cart).
"""
# create the attribute
product_attribute = self.env['product.attribute'].create({
'name': "Dynamic Attribute",
'create_variant': 'dynamic',
})
# create the attribute values
product_attribute_values = self.env['product.attribute.value'].create([{
'name': "Dynamic Value 1",
'attribute_id': product_attribute.id,
'sequence': 1,
}, {
'name': "Dynamic Value 2",
'attribute_id': product_attribute.id,
'sequence': 2,
}])
# create the template
product_template = self.env['product.template'].create({
'name': 'Dynamic Product',
'website_published': True,
'list_price': 0,
})
# set attribute and attribute values on the template
self.env['product.template.attribute.line'].create([{
'attribute_id': product_attribute.id,
'product_tmpl_id': product_template.id,
'value_ids': [(6, 0, product_attribute_values.ids)]
}])
# set a different price on the variants to differentiate them
product_template_attribute_values = self.env['product.template.attribute.value'] \
.search([('product_tmpl_id', '=', product_template.id)])
for ptav in product_template_attribute_values:
if ptav.name == "Dynamic Value 1":
ptav.price_extra = 10
else:
# 0 to not bother with the pricelist of the public user
ptav.price_extra = 0
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_dynamic_variants')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_dynamic_variants.ready")
def test_04_portal_tour_deleted_archived_variants(self):
"""The goal of this test is to make sure deleted and archived variants
are shown as impossible combinations.
Using "portal" to have various users in the tests.
"""
# create the attribute
product_attribute = self.env['product.attribute'].create({
'name': "My Attribute",
'create_variant': 'always',
})
# create the attribute values
product_attribute_values = self.env['product.attribute.value'].create([{
'name': "My Value 1",
'attribute_id': product_attribute.id,
'sequence': 1,
}, {
'name': "My Value 2",
'attribute_id': product_attribute.id,
'sequence': 2,
}, {
'name': "My Value 3",
'attribute_id': product_attribute.id,
'sequence': 3,
}])
# create the template
product_template = self.env['product.template'].create({
'name': 'Test Product',
'website_published': True,
})
# set attribute and attribute values on the template
self.env['product.template.attribute.line'].create([{
'attribute_id': product_attribute.id,
'product_tmpl_id': product_template.id,
'value_ids': [(6, 0, product_attribute_values.ids)]
}])
# set a different price on the variants to differentiate them
product_template_attribute_values = self.env['product.template.attribute.value'] \
.search([('product_tmpl_id', '=', product_template.id)])
product_template_attribute_values[0].price_extra = 10
product_template_attribute_values[1].price_extra = 20
product_template_attribute_values[2].price_extra = 30
product_template.create_variant_ids()
# archive first and delete second
product_template.product_variant_ids[0].active = False
product_template.product_variant_ids[1].unlink()
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_deleted_archived_variants')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_deleted_archived_variants.ready", login="portal")
def test_05_demo_tour_no_variant_attribute(self):
"""The goal of this test is to make sure attributes no_variant are
correctly added to cart.
Using "demo" to have various users in the tests.
"""
# create the attribute
product_attribute_no_variant = self.env['product.attribute'].create({
'name': "No Variant Attribute",
'create_variant': 'no_variant',
})
# create the attribute value
product_attribute_value_no_variant = self.env['product.attribute.value'].create({
'name': "No Variant Value",
'attribute_id': product_attribute_no_variant.id,
})
# create the template
product_template = self.env['product.template'].create({
'name': 'Test Product',
'website_published': True,
})
# set attribute and attribute value on the template
ptal = self.env['product.template.attribute.line'].create([{
'attribute_id': product_attribute_no_variant.id,
'product_tmpl_id': product_template.id,
'value_ids': [(6, 0, product_attribute_value_no_variant.ids)]
}])
# set a price on the value
ptal.product_template_value_ids.price_extra = 10
product_template.create_variant_ids()
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_no_variant_attribute')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_no_variant_attribute.ready", login="demo")
@@ -52,7 +52,7 @@ class TestWebsitePriceList(TransactionCase):
)
return pls
def _test_get_pricelist_available_show(self):
def test_get_pricelist_available_show(self):
show = True
current_pl = False
@@ -68,7 +68,7 @@ class TestWebsitePriceList(TransactionCase):
self.assertEquals(len(set(pls.mapped('name')) & set(result)), len(pls), 'Test failed for %s (%s %s vs %s %s)'
% (country, len(pls), pls.mapped('name'), len(result), result))
def _test_get_pricelist_available_not_show(self):
def test_get_pricelist_available_not_show(self):
show = False
current_pl = False
@@ -85,7 +85,7 @@ class TestWebsitePriceList(TransactionCase):
self.assertEquals(len(set(pls.mapped('name')) & set(result)), len(pls), 'Test failed for %s (%s %s vs %s %s)'
% (country, len(pls), pls.mapped('name'), len(result), result))
def _test_get_pricelist_available_promocode(self):
def test_get_pricelist_available_promocode(self):
christmas_pl = self.ref('website_sale.list_christmas')
public_pl = self.ref('product.list0')
self.args = {
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.product.tests.test_product_attribute_value_config import TestProductAttributeValueSetup
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestWebsiteSaleProductAttributeValueConfig(TestProductAttributeValueSetup):
def test_get_combination_info(self):
current_website = self.env['website'].get_current_website()
pricelist = current_website.get_current_pricelist()
self.computer = self.computer.with_context(website_id=current_website.id)
# make sure the pricelist has a 10% discount
pricelist.item_ids = self.env['product.pricelist.item'].create({
'price_discount': 10,
'compute_price': 'formula',
})
discount_rate = 0.9
# make sure there is a 15% tax on the product
tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 15})
self.computer.taxes_id = tax
tax_ratio = (100 + tax.amount) / 100
# easy currency rate, for a simple 2 ratio in the following tests
self.computer.currency_id.rate_ids[0].rate = 1
ratio = 2
currency = self.env['res.currency'].browse(1 if self.computer.currency_id.id != 1 else 2)
currency.rate_ids[0].rate = ratio
pricelist.currency_id = currency
# ensure pricelist is set to with_discount
pricelist.discount_policy = 'with_discount'
self.computer.invalidate_cache()
# CASE: B2B setting
self.env.ref('account.group_show_line_subtotals_tax_excluded').users |= self.env.user
self.env.ref('account.group_show_line_subtotals_tax_included').users -= self.env.user
combination_info = self.computer._get_combination_info()
self.assertEqual(combination_info['price'], 2222 * discount_rate * ratio)
self.assertEqual(combination_info['list_price'], 2222 * discount_rate * ratio)
self.assertEqual(combination_info['has_discounted_price'], False)
# CASE: B2C setting
self.env.ref('account.group_show_line_subtotals_tax_included').users |= self.env.user
self.env.ref('account.group_show_line_subtotals_tax_excluded').users -= self.env.user
combination_info = self.computer._get_combination_info()
self.assertEqual(combination_info['price'], 2222 * discount_rate * ratio * tax_ratio)
self.assertEqual(combination_info['list_price'], 2222 * discount_rate * ratio * tax_ratio)
self.assertEqual(combination_info['has_discounted_price'], False)
# CASE: pricelist 'without_discount'
pricelist.discount_policy = 'without_discount'
self.computer.invalidate_cache()
# ideally we would need to use compare_amounts everywhere, but this is
# the only rounding where it fails without it
combination_info = self.computer._get_combination_info()
self.assertEqual(pricelist.currency_id.compare_amounts(combination_info['price'], 2222 * discount_rate * ratio * tax_ratio), 0)
self.assertEqual(pricelist.currency_id.compare_amounts(combination_info['list_price'], 2222 * ratio * tax_ratio), 0)
self.assertEqual(combination_info['has_discounted_price'], True)
@@ -34,14 +34,17 @@ class TestWebsiteSaleZoom(odoo.tests.HttpCase):
'name': name_red,
'attribute_id': product_attribute.id,
'html_color': color_red,
'sequence': 1,
}, {
'name': name_green,
'attribute_id': product_attribute.id,
'html_color': color_green,
'sequence': 2,
}, {
'name': name_blue,
'attribute_id': product_attribute.id,
'html_color': color_blue,
'sequence': 3,
}])
# first image (blue) for the template
+132 -52
View File
@@ -30,6 +30,9 @@
<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_tour_shop_custom_attribute_value.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_zoom.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_options.js"></script>
</xpath>
</template>
@@ -95,6 +98,11 @@
<!-- Product item used by /shop and /shop/cart -->
<template id="products_item" name="Products item">
<t t-set="product_href" t-value="keep('/shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']&gt;1 else None))" />
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<form action="/shop/cart/update" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
<div itemscope="itemscope" itemtype="http://schema.org/Product">
@@ -103,7 +111,8 @@
</div>
<div class="oe_product_image">
<a itemprop="url" t-att-href="product_href">
<span itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.template/{{product.id}}/image" t-field="product.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" />
<span t-if="product_variant" itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.product/{{product_variant.id}}/image" t-field="product_variant.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" />
<span t-else="" itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.template/{{product.id}}/image" t-field="product.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" />
</a>
</div>
<t t-if="show_publish">
@@ -120,14 +129,13 @@
<a itemprop="name" t-att-href="product_href" t-att-content="product.name" t-field="product.name" />
<a role="button" t-if="not show_publish and not product.website_published" t-att-href="product_href" class="btn btn-secondary btn-sm btn-danger" title="This product is unpublished.">Unpublished</a>
</h6>
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price" t-if="product.product_variant_ids">
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price">
<b>
<t t-if="product.product_variant_id and product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" />
<t t-if="True"><!-- compatibility 12.0 -->
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
</t>
<span t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="product.website_price" />
<span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="combination_info['price']" />
<span itemprop="priceCurrency" style="display:none;" t-esc="website.currency_id.name" />
</b>
</div>
@@ -146,24 +154,19 @@
<template id="products_add_to_cart" inherit_id="website_sale.products_item" active="False" customize_show="True" name="Add to Cart">
<xpath expr="//div[hasclass('product_price')]" position="inside">
<input name="product_id" t-att-value="product.product_variant_id.id" type="hidden" />
<t t-if="len(product.product_variant_ids) == 1">
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
<input name="product_id" t-att-value="product_variant.id" type="hidden" />
<t t-if="product._is_quick_add_to_cart_possible()">
<a href="#" role="button" class="btn btn-secondary btn-sm a-submit" aria-label="Shopping cart" title="Shopping cart">
<span class="fa fa-shopping-cart" />
</a>
</t>
<t t-if="len(product.product_variant_ids) &gt; 1">
<t t-elif="product._is_add_to_cart_possible()">
<a role="button" class="btn btn-secondary btn-sm" t-att-href="keep('/shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']&gt;1 else None))" aria-label="Shopping cart" title="Shopping cart">
<span class="fa fa-shopping-cart" />
</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">
@@ -414,10 +417,15 @@
</xpath>
</template>
<template id="product" name="Product">
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<t t-call="website.layout">
<t t-set="additional_title" t-value="product.name" />
<div itemscope="itemscope" itemtype="http://schema.org/Product" id="wrap" class="js_sale">
<section t-attf-class="container py-2 oe_website_sale #{(compute_currency(product.lst_price) - product.website_price) &gt; 0.01 and website.get_current_pricelist().discount_policy == 'without_discount' and 'discount'}" id="product_detail">
<section t-attf-class="container py-2 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail">
<div class="row">
<div class="col-md-4">
<ol class="breadcrumb">
@@ -443,12 +451,12 @@
</div>
<div class="row">
<div class="col-md-6">
<t t-set="variant_img" t-value="any(product.mapped('product_variant_ids.image_variant'))"/>
<t t-set="variant_img" t-value="product_variant and product_variant.image_variant"/>
<t t-set="image_ids" t-value="product.product_image_ids"/>
<div id="o-carousel-product" class="carousel slide" data-ride="carousel" data-interval="0">
<div class="carousel-outer">
<div class="carousel-inner">
<div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product[:1].product_variant_id.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product.product_variant_id['__last_update']) or '')}"/>
<div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product_variant.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product_variant['__last_update']) or '')}"/>
<div t-attf-class="carousel-item#{'' if variant_img else ' active'}" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/>
<t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg">
<div class="carousel-item" t-field="pimg.image" t-options='{"widget": "image", "class": "product_detail_img", "alt-field": "name", "zoom": "image" }'/>
@@ -467,7 +475,7 @@
<ol class="carousel-indicators" t-if="len(image_ids) or variant_img">
<li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="0" class="active">
<img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product.product_variant_id.id}}/image/90x90" t-att-alt="product.name"/>
<img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product_variant.id}}/image/90x90" t-att-alt="product.name"/>
</li>
<li data-target="#o-carousel-product" t-att-data-slide-to="1 if variant_img else '0'" t-att-class="'' if variant_img else 'active'">
<img class="img img-fluid" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/>
@@ -483,21 +491,27 @@
<div class="col-md-6 col-xl-4 offset-xl-2" id="product_details">
<h1 itemprop="name" t-field="product.name">Product Name</h1>
<span itemprop="url" style="display:none;" t-esc="'%sshop/product/%s' % (request.httprequest.url_root, slug(product))"/>
<form action="/shop/cart/update" method="POST">
<form t-if="product._is_add_to_cart_possible()" action="/shop/cart/update" method="POST">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
<div class="js_product js_main_product">
<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'" />
<input type="hidden" class="product_id" name="product_id" t-att-value="product_variant.id" />
<input type="hidden" class="product_template_id" name="product_template_id" t-att-value="product.id" />
<t t-call="sale.variants">
<t t-if="first_possible_combination" t-call="sale.variants">
<t t-set="ul_class" t-value="'flex-column'" />
<t t-set="parent_combination" t-value="None" />
</t>
<t t-else="">
<ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"/>
</t>
</t>
<t t-call="website_sale.product_price" />
<p t-if="product.has_dynamic_attributes() or len(product.product_variant_ids) &gt; 1" class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
<p t-if="True" class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
<a role="button" id="add_to_cart" class="btn btn-primary btn-lg mt8 js_check_product a-submit" href="#">Add to Cart</a>
</div>
</form>
<p t-elif="not product.active" class="alert alert-warning">This product is no longer available.</p>
<p t-else="" class="alert alert-warning">This product has no valid combination.</p>
<hr t-if="product.description_sale" />
<div class="o_not_editable">
<p t-field="product.description_sale" class="text-muted" />
@@ -538,7 +552,10 @@
<t t-foreach="product.alternative_product_ids" t-as="alt_product">
<div class="col-lg-2" style="width: 170px; height:130px; float:left; display:inline; margin-right: 10px; overflow:hidden;">
<div class="mt16 text-center" style="height: 100%;">
<div t-field="alt_product.image_small" t-options="{'widget': 'image', 'class': 'rounded shadow o_alternative_product' }" />
<t t-set="combination_info" t-value="alt_product._get_combination_info()"/>
<t t-set="product_variant" t-value="alt_product.env['product.product'].browse(combination_info['product_id'])"/>
<div t-if="product_variant" t-field="product_variant.image_small" t-options="{'widget': 'image', 'class': 'rounded shadow o_alternative_product' }" />
<div t-else="" t-field="alt_product.image_small" t-options="{'widget': 'image', 'class': 'rounded shadow o_alternative_product' }" />
<h6>
<a t-attf-href="/shop/product/#{ slug(alt_product) }" style="display: block">
<span t-att-title="alt_product.name" t-field="alt_product.name" class="o_text_overflow" style="display: block;" />
@@ -554,9 +571,9 @@
<template id="product_attributes" inherit_id="website_sale.product" name="Product attributes">
<xpath expr="//p[@t-field='product.description_sale']" position="after">
<hr t-if="sum([(1 if len(l.value_ids)==1 else 0) for l in product.attribute_line_ids])" id='hr_product_attributes_simple'/>
<hr t-if="sum([(1 if len(l.value_ids)==1 else 0) for l in product._get_valid_product_template_attribute_lines()])" id='hr_product_attributes_simple'/>
<p class="text-muted" id="product_attributes_simple">
<t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id">
<t t-foreach="product._get_valid_product_template_attribute_lines()" t-as="variant_id">
<t t-if="len(variant_id.value_ids)==1">
<span t-field="variant_id.attribute_id" />: <span t-field="variant_id.value_ids[0].name" /><br /></t>
</t>
@@ -591,7 +608,7 @@
<i class="fa fa-minus"></i>
</a>
</div>
<input type="text" class="form-control quantity" data-min="1" name="add_qty" value="1"/>
<input type="text" class="form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/>
<div class="input-group-append">
<a t-attf-href="#" class="btn btn-secondary float_left js_add_cart_json" aria-label="Add one" title="Add one">
<i class="fa fa-plus"></i>
@@ -604,15 +621,15 @@
<template id="product_price">
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price mt16">
<h4 class="oe_price_h4 css_editable_mode_hidden">
<span class="text-danger oe_default_price" style="text-decoration: line-through; white-space: nowrap;"
t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if product.product_variant_id and product.website_price_difference and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'"
<span t-attf-class="text-danger oe_default_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="text-decoration: line-through; white-space: nowrap;"
t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"
/>
<b class="oe_price" style="white-space: nowrap;" t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="product.website_price"/>
<b class="oe_price" style="white-space: nowrap;" t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="combination_info['price']"/>
<span itemprop="priceCurrency" style="display:none;" t-esc="website.currency_id.name"/>
</h4>
<h4 class="css_non_editable_mode_hidden decimal_precision" t-att-data-precision="str(product.currency_id.decimal_places)">
<span t-field="product.lst_price"
<span t-field="product.list_price"
t-options='{
"widget": "monetary",
"display_currency": product.currency_id,
@@ -624,21 +641,37 @@
<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_exclusions" t-value="get_attribute_exclusions(product, reference_product)"/>
<!--
Using this setting with dynamic variants is not supported.
Indeed the variants that have yet to exist will not show on the
list and will never be selectable to be created...
We also don't use the feature with no_variant because these
attributes have to be selected manually.
Finally we don't use the feature with is_custom values because
they need to be set by the user.
-->
<t t-if="not product.has_dynamic_attributes() and not product._has_no_variant_attributes() and not product._has_is_custom_values()">
<t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions()"/>
<t t-set="filtered_sorted_variants" t-value="product._get_possible_variants_sorted()"/>
<ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)"/>
<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">
<div t-foreach="product.product_variant_ids" t-as="variant_id" class="custom-control custom-radio">
<input type="radio" name="product_id" class="custom-control-input js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-attf-id="radio_variant_#{variant_id.id}" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/>
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
<input type="hidden" t-if="len(filtered_sorted_variants) == 1" class="product_id" name="product_id" t-att-value="filtered_sorted_variants[0].id"/>
<t t-if="len(filtered_sorted_variants) &gt; 1">
<div t-foreach="filtered_sorted_variants" t-as="variant_id" class="custom-control custom-radio">
<input type="radio" name="product_id" class="custom-control-input product_id js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-attf-id="radio_variant_#{variant_id.id}" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price" t-att-data-combination="variant_id.product_template_attribute_value_ids.ids"/>
<label t-attf-for="radio_variant_#{variant_id.id}" label-default="label-default" class="custom-control-label">
<span t-esc="variant_id.name_get()[0][1]"/>
<t t-set="diff_price" t-value="variant_id.price - variant_id.product_tmpl_id.price"/>
<t t-set="diff_price" t-value="website.currency_id.compare_amounts(variant_id.price, variant_id.product_tmpl_id.price)"/>
<span class="badge badge-pill" t-if="diff_price != 0">
<t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(variant_id.price - variant_id.product_tmpl_id.price)" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</span>
</label>
</div>
</t>
</t>
<t t-else="">$0</t>
</xpath>
</template>
@@ -821,11 +854,18 @@
</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'">
<del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="compute_currency(line.product_id.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" />
<t t-set="combination" t-value="line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids"/>
<t t-set="combination_info" t-value="line.product_id.product_tmpl_id._get_combination_info(combination)"/>
<t t-set="list_price_converted" t-value="website.currency_id._convert(combination_info['list_price'], website_sale_order.currency_id, website_sale_order.company_id, date)"/>
<t groups="account.group_show_line_subtotals_tax_excluded" t-if="(website_sale_order.pricelist_id.discount_policy == 'without_discount' and website_sale_order.currency_id.compare_amounts(list_price_converted, line.price_reduce_taxexcl) == 1) or website_sale_order.currency_id.compare_amounts(line.price_unit, line.price_reduce) == 1" name="order_line_discount">
<del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="list_price_converted" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" />
</t>
<span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" />
<span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_included" />
<span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" />
<t groups="account.group_show_line_subtotals_tax_included" t-if="(website_sale_order.pricelist_id.discount_policy == 'without_discount' and website_sale_order.currency_id.compare_amounts(list_price_converted, line.price_reduce_taxinc) == 1) or website_sale_order.currency_id.compare_amounts(line.price_unit, line.price_reduce) == 1" name="order_line_discount">
<del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="list_price_converted" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" />
</t>
<span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_included" />
</td>
<td class="td-action">
<a href='#' aria-label="Remove from cart" title="Remove from cart" class='js_delete_product no-decoration'> <small><i class='fa fa-trash-o'></i></small></a>
@@ -938,6 +978,7 @@
<table t-if="suggested_products" id="suggested_products" class="js_cart_lines table table-striped table-sm">
<tbody>
<tr t-foreach="suggested_products" t-as="product">
<t t-set="combination_info" t-value="product._get_combination_info_variant()"/>
<td class='td-img'>
<a t-attf-href="/shop/product/#{ slug(product.product_tmpl_id) }">
<span t-field="product.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" />
@@ -946,16 +987,16 @@
<td class='td-product_name'>
<div>
<a t-attf-href="/shop/product/#{ slug(product.product_tmpl_id) }">
<strong t-field="product.display_name" />
<strong t-esc="combination_info['display_name']" />
</a>
</div>
<div class="text-muted d-none d-md-block" t-field="product.description_sale" />
</td>
<td class='td-price'>
<t t-if="product.product_variant_id and product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-field="product.lst_price" t-options="{'widget': 'monetary','from_currency': product.currency_id, 'display_currency': website.currency_id}" />
<t t-if="True"><!-- compatibility 12.0 -->
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</t>
<span t-field="product.website_price" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}" />
<span t-esc="combination_info['price']" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}"/>
</td>
<td class="w-25 text-center">
<input class="js_quantity" name="product_id" t-att-data-product-id="product.id" type="hidden" />
@@ -1396,8 +1437,8 @@
<div t-esc="line.product_uom_qty" />
</td>
<td class="text-center td-price">
<span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" />
<span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_included" />
<span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" />
<span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_included" />
</td>
</tr>
</tbody>
@@ -1499,13 +1540,13 @@
<tr id="order_total_untaxed">
<td class="text-right noborder">Subtotal:</td>
<td class="text-xl-right noborder" >
<span t-field="website_sale_order.amount_untaxed" style="white-space: nowrap;" t-options="{'widget': 'monetary','from_currency': website_sale_order.pricelist_id.currency_id,'display_currency': website.currency_id}" />
<span t-field="website_sale_order.amount_untaxed" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}"/>
</td>
</tr>
<tr id="order_total_taxes">
<td class="text-right noborder">Taxes:</td>
<td class="text-xl-right noborder">
<span t-field="website_sale_order.amount_tax" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" />
<span t-field="website_sale_order.amount_tax" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" />
</td>
</tr>
<tr id="order_total">
@@ -1663,4 +1704,43 @@
</t>
</template>
<template id="website_sale.shop_product_carousel" name="Shop Product Carousel">
<t t-set="variant_img" t-value="product_variant and product_variant.image_variant"/>
<t t-set="image_ids" t-value="product.product_image_ids"/>
<div id="o-carousel-product" class="carousel slide" data-ride="carousel" data-interval="0">
<div class="carousel-outer">
<div class="carousel-inner">
<div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product_variant.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product_variant['__last_update']) or '')}"/>
<div t-attf-class="carousel-item#{'' if variant_img else ' active'}" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/>
<t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg">
<div class="carousel-item" t-field="pimg.image" t-options='{"widget": "image", "class": "product_detail_img", "alt-field": "name", "zoom": "image" }'/>
</t>
</div>
<t t-if="len(image_ids) or variant_img">
<a class="carousel-control-prev" href="#o-carousel-product" role="button" data-slide="prev" >
<span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
</a>
<a class="carousel-control-next" href="#o-carousel-product" role="button" data-slide="next">
<span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/>
</a>
</t>
</div>
<ol t-if="len(image_ids) or variant_img" class="carousel-indicators">
<li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="0" class="active">
<img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product_variant.id}}/image/90x90" t-att-alt="product.name"/>
</li>
<li data-target="#o-carousel-product" t-att-data-slide-to="1 if variant_img else '0'" t-att-class="'' if variant_img else 'active'">
<img class="img img-fluid" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/>
</li>
<t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg">
<li data-target="#o-carousel-product" t-att-data-slide-to="pimg_index + (variant_img and 2 or 1)">
<img class="img img-fluid" t-attf-src="/website/image/product.image/{{pimg.id}}/image/90x90" t-att-alt="pimg.name"/>
</li>
</t>
</ol>
</div>
</template>
</odoo>
@@ -19,7 +19,7 @@ class WebsiteSaleProductComparison(WebsiteSale):
res = {}
for num, product in enumerate(products):
for var in product.attribute_line_ids:
for var in product.product_tmpl_id._get_valid_product_template_attribute_lines():
cat_name = var.attribute_id.category_id.name or _('Uncategorized')
att_name = var.attribute_id.name
if not product.attribute_value_ids: # create_variant = False
@@ -29,14 +29,15 @@ class WebsiteSaleProductComparison(WebsiteSale):
if val:
res[cat_name][att_name][num] = val[0].name
values['specs'] = res
values['compute_currency'] = self._get_compute_currency_and_context()[0]
values['compute_currency'] = self._get_compute_currency_and_context(products[:1])[0]
return request.render("website_sale_comparison.product_compare", values)
@http.route(['/shop/get_product_data'], type='json', auth="public", website=True)
def get_product_data(self, product_ids, cookies=None):
ret = {}
compute_currency, pricelist_context, _ = self._get_compute_currency_and_context()
pricelist_context, pricelist = self._get_pricelist_context()
prods = request.env['product.product'].with_context(pricelist_context, display_default_code=False).search([('id', 'in', product_ids)])
compute_currency = self._get_compute_currency(pricelist, prods[:1])
if cookies is not None:
ret['cookies'] = json.dumps(request.env['product.product'].search([('id', 'in', list(set(product_ids + cookies)))]).ids)
@@ -26,6 +26,6 @@ class ProductTemplate(models.Model):
def get_variant_groups(self):
res = OrderedDict()
for var in self.attribute_line_ids:
for var in self._get_valid_product_template_attribute_lines():
res.setdefault(var.attribute_id.category_id.name or _('Uncategorized'), []).append(var)
return res
@@ -10,14 +10,16 @@
<template id="add_to_compare" inherit_id="website_sale.products_item" active="True" customize_show="True" name="Comparison List" priority="22">
<xpath expr="//div[hasclass('product_price')]" position="inside">
<button type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-sm o_add_compare" title="Compare" aria-label="Compare" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_comparelist"><span class="fa fa-exchange"></span></button>
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
<button t-if="product_variant" type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-sm o_add_compare" title="Compare" aria-label="Compare" t-att-data-product-product-id="product_variant.id" data-action="o_comparelist"><span class="fa fa-exchange"></span></button>
</xpath>
</template>
<template id="product_add_to_compare" name='Add to comparison in product page' inherit_id="website_sale.product" priority="8">
<xpath expr="//a[@id='add_to_cart']" position="after">
<t t-if="product.env.ref('website_sale_comparison.add_to_compare').active">
<button type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-lg mt8 o_add_compare_dyn" title="Compare" aria-label="Compare" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_comparelist"><span class="fa fa-exchange"></span></button>
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
<button t-if="product_variant" type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-lg mt8 o_add_compare_dyn" title="Compare" aria-label="Compare" t-att-data-product-product-id="product_variant.id" data-action="o_comparelist"><span class="fa fa-exchange"></span></button>
</t>
</xpath>
</template>
@@ -57,8 +59,19 @@
<template id='recommended_product' inherit_id="website_sale.recommended_products" name="Alternative Products">
<xpath expr="//h3" position="replace">
<t t-set="ids" t-value="str(product.product_variant_id.id) + ','+ ','.join([str(a) for a in product.alternative_product_ids.mapped('product_variant_id.id')])"/>
<div><span class='h3'>Suggested alternatives: </span><a role="button" class="btn btn-primary" t-attf-href="/shop/compare/?products=#{ids}"><i class="fa fa-exchange"></i> Compare</a></div>
<!--
Create the first variant if it doesn't exist for the current
product and every alternative product because the product
comparator need the variants and not the templates.
-->
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
<t t-set="id_list" t-value="[product_variant.id] if product_variant else []"/>
<t t-foreach="product.alternative_product_ids" t-as="alt_product">
<t t-set="alt_product_variant_id" t-value="alt_product._get_combination_info()['product_id'] or alt_product._create_first_product_variant().id"/>
<t t-if="alt_product_variant_id" t-set="id_list" t-value="id_list + [alt_product_variant_id]"/>
</t>
<t t-set="ids" t-value="','.join([str(id) for id in id_list])"/>
<div><span class='h3'>Suggested alternatives: </span><a t-if="len(id_list) > 1" role="button" class="btn btn-primary" t-attf-href="/shop/compare/?products=#{ids}"><i class="fa fa-exchange"></i> Compare</a></div>
</xpath>
</template>
@@ -74,6 +87,7 @@
<thead>
<td t-if="specs.keys()" class='td-top-left'/>
<td t-foreach="products" t-as="product">
<t t-set="combination_info" t-value="product._get_combination_info_variant()"/>
<a href="#" t-att-data-product_product_id="product.id" class="o_comparelist_remove" t-if="len(products) &gt; 2">
<strong class='float-right'>x</strong>
</a>
@@ -82,15 +96,15 @@
</a>
<div class='product_summary'>
<a class="o_product_comparison_table" t-attf-href="/shop/product/#{ slug(product.product_tmpl_id) }">
<span t-field='product.display_name'></span><br/>
<span t-esc="combination_info['display_name']"></span><br/>
</a>
<span>
<strong>Price:</strong>
<t t-if="(compute_currency(product.lst_price) - product.website_price ) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" />
<t t-if="True"><!-- compatibility 12.0-->
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
</t>
<span t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span>
<span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span>
</span>
<form action="/shop/cart/update" method="post" class="text-center">
@@ -121,17 +135,18 @@
</template>
<template id="product_product" name="Comparator - Product row in comparator popover">
<t t-set="combination_info" t-value="product._get_combination_info_variant()"/>
<div class="row no-gutters align-items-center mt-2 o_product_row" t-att-data-category_ids="product.public_categ_ids.ids">
<div class="col-3">
<img class="img img-fluid" t-attf-src="/web/image/product.product/#{product.id}/image_small" style="max-height: 50px;" alt="Product image"/>
</div>
<div class="col-8 pl-2">
<h6>
<a t-attf-href="/shop/product/#{product.product_tmpl_id[0].id}"><t t-esc="product.display_name" /></a><br/>
<t t-if="(compute_currency(product.lst_price) - product.website_price ) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" />
<a t-attf-href="/shop/product/#{product.product_tmpl_id[0].id}"><t t-esc="combination_info['display_name']" /></a><br/>
<t t-if="True"><!--compatibility 12.0-->
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
</t>
<span t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span>
<span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span>
</h6>
</div>
<div class="col-1 text-right">
+4 -27
View File
@@ -4,32 +4,9 @@
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.http import request
class WebsiteSale(WebsiteSale):
def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw):
res = super(WebsiteSale, self)._get_combination_info(product_template_id, product_id, combination, add_qty, pricelist, **kw)
if res['product_id']:
product = request.env['product.product'].sudo().browse(res['product_id'])
res.update({
'virtual_available': product.virtual_available,
'product_type': product.type,
'inventory_availability': product.inventory_availability,
'available_threshold': product.available_threshold,
'custom_message': product.custom_message,
'product_template': product.product_tmpl_id.id,
'cart_qty': product.cart_qty,
'uom_name': product.uom_id.name,
})
else:
product_template = request.env['product.template'].sudo().browse(product_template_id)
res.update({
'virtual_available': 0,
'product_type': product_template.type,
'inventory_availability': product_template.inventory_availability,
'available_threshold': product_template.available_threshold,
'custom_message': product_template.custom_message,
'product_template': product_template.id,
'cart_qty': 0
})
return res
"""deprecated, use product method"""
combination = request.env['product.template.attribute.value'].browse(combination)
return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, product_id, add_qty, pricelist)
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import fields, models, api
class ProductTemplate(models.Model):
@@ -15,3 +15,33 @@ class ProductTemplate(models.Model):
], string='Inventory Availability', help='Adds an inventory availability status on the web product page.', default='never')
available_threshold = fields.Float(string='Availability Threshold', default=5.0)
custom_message = fields.Text(string='Custom Message', default='')
@api.multi
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False):
combination_info = super(ProductTemplate, self)._get_combination_info(combination, product_id, add_qty, pricelist, reference_product)
if combination_info['product_id']:
product = self.env['product.product'].sudo().browse(combination_info['product_id'])
combination_info.update({
'virtual_available': product.virtual_available,
'product_type': product.type,
'inventory_availability': product.inventory_availability,
'available_threshold': product.available_threshold,
'custom_message': product.custom_message,
'product_template': product.product_tmpl_id.id,
'cart_qty': product.cart_qty,
'uom_name': product.uom_id.name,
})
else:
product_template = self.sudo()
combination_info.update({
'virtual_available': 0,
'product_type': product_template.type,
'inventory_availability': product_template.inventory_availability,
'available_threshold': product_template.available_threshold,
'custom_message': product_template.custom_message,
'product_template': product_template.id,
'cart_qty': 0
})
return combination_info
@@ -27,9 +27,16 @@ var xml_load = ajax.loadXML(
* @param {Array} combination
*/
ProductConfiguratorMixin._onChangeCombinationStock = function (ev, $parent, combination) {
var product_id = 0;
// needed for list view of variants
if ($parent.find('input.product_id:checked').length) {
product_id = $parent.find('input.product_id:checked').val();
} else {
product_id = $parent.find('.product_id').val();
}
var isMainProduct = combination.product_id &&
($parent.is('.js_main_product') || $parent.is('.main_product')) &&
combination.product_id === parseInt($parent.find('.product_id').val());
combination.product_id === parseInt(product_id);
if (!this.isWebsite || !isMainProduct){
return;
@@ -10,9 +10,9 @@ class WebsiteSaleWishlist(WebsiteSale):
@http.route(['/shop/wishlist/add'], type='json', auth="public", website=True)
def add_to_wishlist(self, product_id, price=False, **kw):
if not price:
compute_currency, pricelist_context, pl = self._get_compute_currency_and_context()
pricelist_context, pl = self._get_pricelist_context()
p = request.env['product.product'].with_context(pricelist_context, display_default_code=False).browse(product_id)
price = p.website_price
price = p._get_combination_info_variant()['price']
Wishlist = request.env['product.wishlist']
if request.website.is_public_user():
@@ -74,3 +74,12 @@ class ResPartner(models.Model):
_inherit = 'res.partner'
wishlist_ids = fields.One2many('product.wishlist', 'partner_id', string='Wishlist', domain=[('active', '=', True)])
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.multi
def _is_in_wishlist(self):
self.ensure_one()
return self in self.env['product.wishlist'].current().mapped('product_id.product_tmpl_id')
@@ -34,10 +34,11 @@ sAnimations.registry.ProductWishlist = sAnimations.Class.extend(ProductConfigura
}).then(function (res) {
self.wishlistProductIDs = JSON.parse(res);
self._updateWishlistView();
if ($('input.js_product_change').length) { // manage "List View of variants"
$('input.js_product_change:checked').first().trigger('change');
// trigger change on only one input
if (self.$('input.js_product_change').length) { // manage "List View of variants"
self.$('input.js_product_change:checked').first().trigger('change');
} else {
$('input.js_variant_change').trigger('change');
self.$('input.js_variant_change:checked').first().trigger('change');
}
});
@@ -61,12 +62,17 @@ sAnimations.registry.ProductWishlist = sAnimations.Class.extend(ProductConfigura
}
productID = parseInt(productID, 10);
}
var $form = $el.closest('form');
var templateId = $form.find('.product_template_id').val();
// when adding from /shop instead of the product page, need another selector
if (!templateId) {
templateId = $el.data('product-template-id');
}
$el.prop("disabled", true).addClass('disabled');
var productReady = this.selectOrCreateProduct(
$el.closest('form'),
productID,
$el.closest('form').find('.product_template_id').val(),
templateId,
false
);
@@ -11,16 +11,18 @@
<template id="add_to_wishlist" inherit_id="website_sale.products_item" active="True" customize_show="True" name="Wishlist" priority="20">
<xpath expr="//div[hasclass('product_price')]" position="inside">
<t t-set="in_wish" t-value="product.product_variant_ids &amp; request.env['product.wishlist'].current().mapped('product_id')"/>
<button type="button" role="button" class="btn btn-secondary btn-sm o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
<t t-set="in_wish" t-value="product._is_in_wishlist()"/>
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
<button t-if="product_variant" type="button" role="button" class="btn btn-secondary btn-sm o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant.id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
</xpath>
</template>
<template id="product_add_to_wishlist" name='Add to wishlist in product page' inherit_id="website_sale.product" priority="20">
<xpath expr="//a[@id='add_to_cart']" position="after">
<t t-if="product.env.ref('website_sale_wishlist.add_to_wishlist').active">
<t t-set="in_wish" t-value="product.product_variant_ids &amp; request.env['product.wishlist'].current().mapped('product_id')"/>
<button type="button" role="button" class="btn btn-secondary btn-lg mt8 o_add_wishlist_dyn" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
<t t-set="in_wish" t-value="product._is_in_wishlist()"/>
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
<button t-if="product_variant" type="button" role="button" class="btn btn-secondary btn-lg mt8 o_add_wishlist_dyn" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant.id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
</t>
</xpath>
</template>
@@ -65,7 +67,8 @@
<button type="button" class="btn btn-link o_wish_rm no-decoration"><small><i class='fa fa-trash-o'></i> Remove</small></button>
</td>
<td>
<t t-esc="wish.product_id.website_price" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}"/>
<t t-set="combination_info" t-value="wish.product_id._get_combination_info_variant()"/>
<t t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}"/>
</td>
<td class='text-center td-wish-btn'>
<input name="product_id" t-att-value="wish.product_id.id" type="hidden"/>