[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:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
]}}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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) < 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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']>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) > 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']>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) >= 1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('product_price')]/t[2]" position="attributes">
|
||||
<attribute name="t-if">len(product.product_variant_ids) > 1 or len(product.optional_product_ids) >= 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) > 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) > 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) > 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) > 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 ) > 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) > 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 ) > 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 ) > 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,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 & 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 & 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"/>
|
||||
|
||||
Reference in New Issue
Block a user