Updating the uom_id on a product_product will eventually change also the uom_po_id to have them belonging to the same category. Saving the update call write() on product with a dict of two values (uom_id and uom_po_id) As product.product is inherited from product.template, the write() on the corresponding product_template is called but in this case 2 times for each values. The first time, only uom_id is updated. The _check_uom constraint is triggered and obviously failed. The uom_po_id on the template is not yet updated. This commit makes the sync in the beginning of write() of product.template. Which means before calling the constraint. This commit also makes the onchanges on uom_id acts the same between product.product and product.template. Task : 2197818 closes odoo/odoo#48714 Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
1119 lines
53 KiB
Python
1119 lines
53 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import itertools
|
|
import logging
|
|
|
|
from odoo import api, fields, models, tools, _, SUPERUSER_ID
|
|
from odoo.exceptions import ValidationError, RedirectWarning, UserError
|
|
from odoo.osv import expression
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_name = "product.template"
|
|
_inherit = ['mail.thread', 'mail.activity.mixin', 'image.mixin']
|
|
_description = "Product Template"
|
|
_order = "name"
|
|
|
|
@tools.ormcache()
|
|
def _get_default_category_id(self):
|
|
# Deletion forbidden (at least through unlink)
|
|
return self.env.ref('product.product_category_all')
|
|
|
|
@tools.ormcache()
|
|
def _get_default_uom_id(self):
|
|
# Deletion forbidden (at least through unlink)
|
|
return self.env.ref('uom.product_uom_unit')
|
|
|
|
def _read_group_categ_id(self, categories, domain, order):
|
|
category_ids = self.env.context.get('default_categ_id')
|
|
if not category_ids and self.env.context.get('group_expand'):
|
|
category_ids = categories._search([], order=order, access_rights_uid=SUPERUSER_ID)
|
|
return categories.browse(category_ids)
|
|
|
|
name = fields.Char('Name', index=True, required=True, translate=True)
|
|
sequence = fields.Integer('Sequence', default=1, help='Gives the sequence order when displaying a product list')
|
|
description = fields.Text(
|
|
'Description', translate=True)
|
|
description_purchase = fields.Text(
|
|
'Purchase Description', translate=True)
|
|
description_sale = fields.Text(
|
|
'Sales Description', translate=True,
|
|
help="A description of the Product that you want to communicate to your customers. "
|
|
"This description will be copied to every Sales Order, Delivery Order and Customer Invoice/Credit Note")
|
|
type = fields.Selection([
|
|
('consu', 'Consumable'),
|
|
('service', 'Service')], string='Product Type', default='consu', required=True,
|
|
help='A storable product is a product for which you manage stock. The Inventory app has to be installed.\n'
|
|
'A consumable product is a product for which stock is not managed.\n'
|
|
'A service is a non-material product you provide.')
|
|
categ_id = fields.Many2one(
|
|
'product.category', 'Product Category',
|
|
change_default=True, default=_get_default_category_id, group_expand='_read_group_categ_id',
|
|
required=True, help="Select category for the current product")
|
|
|
|
currency_id = fields.Many2one(
|
|
'res.currency', 'Currency', compute='_compute_currency_id')
|
|
cost_currency_id = fields.Many2one(
|
|
'res.currency', 'Cost Currency', compute='_compute_cost_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='Product Price')
|
|
# list_price: catalog price, user defined
|
|
list_price = fields.Float(
|
|
'Sales Price', default=1.0,
|
|
digits='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='Product Price')
|
|
standard_price = fields.Float(
|
|
'Cost', compute='_compute_standard_price',
|
|
inverse='_set_standard_price', search='_search_standard_price',
|
|
digits='Product Price', groups="base.group_user",
|
|
help="""In Standard Price & AVCO: value of the product (automatically computed in AVCO).
|
|
In FIFO: value of the last unit that left the stock (automatically computed).
|
|
Used to value the product when the purchase cost is not known (e.g. inventory adjustment).
|
|
Used to compute margins on sale orders.""")
|
|
|
|
volume = fields.Float(
|
|
'Volume', compute='_compute_volume', inverse='_set_volume', digits='Volume', store=True)
|
|
volume_uom_name = fields.Char(string='Volume unit of measure label', compute='_compute_volume_uom_name')
|
|
weight = fields.Float(
|
|
'Weight', compute='_compute_weight', digits='Stock Weight',
|
|
inverse='_set_weight', store=True)
|
|
weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name')
|
|
|
|
sale_ok = fields.Boolean('Can be Sold', default=True)
|
|
purchase_ok = fields.Boolean('Can be Purchased', default=True)
|
|
pricelist_id = fields.Many2one(
|
|
'product.pricelist', 'Pricelist', store=False,
|
|
help='Technical field. Used for searching on pricelists, not stored in database.')
|
|
uom_id = fields.Many2one(
|
|
'uom.uom', 'Unit of Measure',
|
|
default=_get_default_uom_id, required=True,
|
|
help="Default unit of measure used for all stock operations.")
|
|
uom_name = fields.Char(string='Unit of Measure Name', related='uom_id.name', readonly=True)
|
|
uom_po_id = fields.Many2one(
|
|
'uom.uom', 'Purchase Unit of Measure',
|
|
default=_get_default_uom_id, required=True,
|
|
help="Default unit of measure used for purchase orders. It must be in the same category as the default unit of measure.")
|
|
company_id = fields.Many2one(
|
|
'res.company', 'Company', index=1)
|
|
packaging_ids = fields.One2many(
|
|
'product.packaging', string="Product Packages", compute="_compute_packaging_ids", inverse="_set_packaging_ids",
|
|
help="Gives the different ways to package the same product.")
|
|
seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id', 'Vendors', help="Define vendor pricelists.")
|
|
variant_seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id')
|
|
|
|
active = fields.Boolean('Active', default=True, help="If unchecked, it will allow you to hide the product without removing it.")
|
|
color = fields.Integer('Color Index')
|
|
|
|
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
|
|
attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes', copy=True)
|
|
|
|
valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
|
|
compute="_compute_valid_product_template_attribute_line_ids", string='Valid Product Attribute Lines', help="Technical compute")
|
|
|
|
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
|
|
# performance: product_variant_id provides prefetching on the first product variant only
|
|
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
|
|
|
|
product_variant_count = fields.Integer(
|
|
'# Product Variants', compute='_compute_product_variant_count')
|
|
|
|
# related to display product product information if is_product_variant
|
|
barcode = fields.Char('Barcode', compute='_compute_barcode', inverse='_set_barcode', search='_search_barcode')
|
|
default_code = fields.Char(
|
|
'Internal Reference', compute='_compute_default_code',
|
|
inverse='_set_default_code', store=True)
|
|
|
|
pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_item_count")
|
|
|
|
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
|
|
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
|
|
|
|
def _compute_item_count(self):
|
|
for template in self:
|
|
# Pricelist item count counts the rules applicable on current template or on its variants.
|
|
template.pricelist_item_count = template.env['product.pricelist.item'].search_count([
|
|
'|', ('product_tmpl_id', '=', template.id), ('product_id', 'in', template.product_variant_ids.ids)])
|
|
|
|
@api.depends('image_1920', 'image_1024')
|
|
def _compute_can_image_1024_be_zoomed(self):
|
|
for template in self:
|
|
template.can_image_1024_be_zoomed = template.image_1920 and tools.is_image_size_above(template.image_1920, template.image_1024)
|
|
|
|
@api.depends('attribute_line_ids', 'attribute_line_ids.value_ids', 'attribute_line_ids.attribute_id.create_variant')
|
|
def _compute_has_configurable_attributes(self):
|
|
"""A product is considered configurable if:
|
|
- It has dynamic attributes
|
|
- It has any attribute line with at least 2 attribute values configured
|
|
"""
|
|
for product in self:
|
|
product.has_configurable_attributes = product.has_dynamic_attributes() or any(len(ptal.value_ids) >= 2 for ptal in product.attribute_line_ids)
|
|
|
|
@api.depends('product_variant_ids')
|
|
def _compute_product_variant_id(self):
|
|
for p in self:
|
|
p.product_variant_id = p.product_variant_ids[:1].id
|
|
|
|
@api.depends('company_id')
|
|
def _compute_currency_id(self):
|
|
main_company = self.env['res.company']._get_main_company()
|
|
for template in self:
|
|
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
|
|
|
|
@api.depends_context('company')
|
|
def _compute_cost_currency_id(self):
|
|
self.cost_currency_id = self.env.company.currency_id.id
|
|
|
|
def _compute_template_price(self):
|
|
prices = self._compute_template_price_no_inverse()
|
|
for template in self:
|
|
template.price = prices.get(template.id, 0.0)
|
|
|
|
def _compute_template_price_no_inverse(self):
|
|
"""The _compute_template_price writes the 'list_price' field with an inverse method
|
|
This method allows computing the price without writing the 'list_price'
|
|
"""
|
|
prices = {}
|
|
pricelist_id_or_name = self._context.get('pricelist')
|
|
if pricelist_id_or_name:
|
|
pricelist = None
|
|
partner = self.env.context.get('partner')
|
|
quantity = self.env.context.get('quantity', 1.0)
|
|
|
|
# Support context pricelists specified as list, display_name or ID for compatibility
|
|
if isinstance(pricelist_id_or_name, list):
|
|
pricelist_id_or_name = pricelist_id_or_name[0]
|
|
if isinstance(pricelist_id_or_name, str):
|
|
pricelist_data = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1)
|
|
if pricelist_data:
|
|
pricelist = self.env['product.pricelist'].browse(pricelist_data[0][0])
|
|
elif isinstance(pricelist_id_or_name, int):
|
|
pricelist = self.env['product.pricelist'].browse(pricelist_id_or_name)
|
|
|
|
if pricelist:
|
|
quantities = [quantity] * len(self)
|
|
partners = [partner] * len(self)
|
|
prices = pricelist.get_products_price(self, quantities, partners)
|
|
|
|
return prices
|
|
|
|
def _set_template_price(self):
|
|
if self._context.get('uom'):
|
|
for template in self:
|
|
value = self.env['uom.uom'].browse(self._context['uom'])._compute_price(template.price, template.uom_id)
|
|
template.write({'list_price': value})
|
|
else:
|
|
self.write({'list_price': self.price})
|
|
|
|
@api.depends_context('company')
|
|
@api.depends('product_variant_ids', 'product_variant_ids.standard_price')
|
|
def _compute_standard_price(self):
|
|
# Depends on force_company context because standard_price is company_dependent
|
|
# on the product_product
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.standard_price = template.product_variant_ids.standard_price
|
|
for template in (self - unique_variants):
|
|
template.standard_price = 0.0
|
|
|
|
def _set_standard_price(self):
|
|
for template in self:
|
|
if len(template.product_variant_ids) == 1:
|
|
template.product_variant_ids.standard_price = template.standard_price
|
|
|
|
def _search_standard_price(self, operator, value):
|
|
products = self.env['product.product'].search([('standard_price', operator, value)], limit=None)
|
|
return [('id', 'in', products.mapped('product_tmpl_id').ids)]
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.volume')
|
|
def _compute_volume(self):
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.volume = template.product_variant_ids.volume
|
|
for template in (self - unique_variants):
|
|
template.volume = 0.0
|
|
|
|
def _set_volume(self):
|
|
for template in self:
|
|
if len(template.product_variant_ids) == 1:
|
|
template.product_variant_ids.volume = template.volume
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.weight')
|
|
def _compute_weight(self):
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.weight = template.product_variant_ids.weight
|
|
for template in (self - unique_variants):
|
|
template.weight = 0.0
|
|
|
|
def _compute_is_product_variant(self):
|
|
self.is_product_variant = False
|
|
|
|
@api.depends('product_variant_ids.barcode')
|
|
def _compute_barcode(self):
|
|
self.barcode = False
|
|
for template in self:
|
|
if len(template.product_variant_ids) == 1:
|
|
template.barcode = template.product_variant_ids.barcode
|
|
|
|
def _search_barcode(self, operator, value):
|
|
templates = self.with_context(active_test=False).search([('product_variant_ids.barcode', operator, value)])
|
|
return [('id', 'in', templates.ids)]
|
|
|
|
def _set_barcode(self):
|
|
if len(self.product_variant_ids) == 1:
|
|
self.product_variant_ids.barcode = self.barcode
|
|
|
|
@api.model
|
|
def _get_weight_uom_id_from_ir_config_parameter(self):
|
|
""" Get the unit of measure to interpret the `weight` field. By default, we considerer
|
|
that weights are expressed in kilograms. Users can configure to express them in pounds
|
|
by adding an ir.config_parameter record with "product.product_weight_in_lbs" as key
|
|
and "1" as value.
|
|
"""
|
|
product_weight_in_lbs_param = self.env['ir.config_parameter'].sudo().get_param('product.weight_in_lbs')
|
|
if product_weight_in_lbs_param == '1':
|
|
return self.env.ref('uom.product_uom_lb', False) or self.env['uom.uom'].search([('measure_type', '=' , 'weight'), ('uom_type', '=', 'reference')], limit=1)
|
|
else:
|
|
return self.env.ref('uom.product_uom_kgm', False) or self.env['uom.uom'].search([('measure_type', '=' , 'weight'), ('uom_type', '=', 'reference')], limit=1)
|
|
|
|
@api.model
|
|
def _get_length_uom_id_from_ir_config_parameter(self):
|
|
""" Get the unit of measure to interpret the `length`, 'width', 'height' field.
|
|
By default, we considerer that length are expressed in meters. Users can configure
|
|
to express them in feet by adding an ir.config_parameter record with "product.volume_in_cubic_feet"
|
|
as key and "1" as value.
|
|
"""
|
|
product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet')
|
|
if product_length_in_feet_param == '1':
|
|
return self.env.ref('uom.product_uom_foot')
|
|
else:
|
|
return self.env.ref('uom.product_uom_meter')
|
|
|
|
@api.model
|
|
def _get_volume_uom_id_from_ir_config_parameter(self):
|
|
""" Get the unit of measure to interpret the `volume` field. By default, we consider
|
|
that volumes are expressed in cubic meters. Users can configure to express them in cubic feet
|
|
by adding an ir.config_parameter record with "product.volume_in_cubic_feet" as key
|
|
and "1" as value.
|
|
"""
|
|
product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet')
|
|
if product_length_in_feet_param == '1':
|
|
return self.env.ref('uom.product_uom_cubic_foot')
|
|
else:
|
|
return self.env.ref('uom.product_uom_cubic_meter')
|
|
|
|
@api.model
|
|
def _get_weight_uom_name_from_ir_config_parameter(self):
|
|
return self._get_weight_uom_id_from_ir_config_parameter().display_name
|
|
|
|
@api.model
|
|
def _get_length_uom_name_from_ir_config_parameter(self):
|
|
return self._get_length_uom_id_from_ir_config_parameter().display_name
|
|
|
|
@api.model
|
|
def _get_volume_uom_name_from_ir_config_parameter(self):
|
|
return self._get_volume_uom_id_from_ir_config_parameter().display_name
|
|
|
|
def _compute_weight_uom_name(self):
|
|
self.weight_uom_name = self._get_weight_uom_name_from_ir_config_parameter()
|
|
|
|
def _compute_volume_uom_name(self):
|
|
self.volume_uom_name = self._get_volume_uom_name_from_ir_config_parameter()
|
|
|
|
def _set_weight(self):
|
|
for template in self:
|
|
if len(template.product_variant_ids) == 1:
|
|
template.product_variant_ids.weight = template.weight
|
|
|
|
@api.depends('product_variant_ids.product_tmpl_id')
|
|
def _compute_product_variant_count(self):
|
|
for template in self:
|
|
# do not pollute variants to be prefetched when counting variants
|
|
template.product_variant_count = len(template.with_prefetch().product_variant_ids)
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.default_code')
|
|
def _compute_default_code(self):
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.default_code = template.product_variant_ids.default_code
|
|
for template in (self - unique_variants):
|
|
template.default_code = False
|
|
|
|
def _set_default_code(self):
|
|
for template in self:
|
|
if len(template.product_variant_ids) == 1:
|
|
template.product_variant_ids.default_code = template.default_code
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.packaging_ids')
|
|
def _compute_packaging_ids(self):
|
|
for p in self:
|
|
if len(p.product_variant_ids) == 1:
|
|
p.packaging_ids = p.product_variant_ids.packaging_ids
|
|
else:
|
|
p.packaging_ids = False
|
|
|
|
def _set_packaging_ids(self):
|
|
for p in self:
|
|
if len(p.product_variant_ids) == 1:
|
|
p.product_variant_ids.packaging_ids = p.packaging_ids
|
|
|
|
@api.constrains('uom_id', 'uom_po_id')
|
|
def _check_uom(self):
|
|
if any(template.uom_id and template.uom_po_id and template.uom_id.category_id != template.uom_po_id.category_id for template in self):
|
|
raise ValidationError(_('The default Unit of Measure and the purchase Unit of Measure must be in the same category.'))
|
|
return True
|
|
|
|
@api.onchange('uom_id')
|
|
def _onchange_uom_id(self):
|
|
if self.uom_id:
|
|
self.uom_po_id = self.uom_id.id
|
|
|
|
@api.onchange('uom_po_id')
|
|
def _onchange_uom(self):
|
|
if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id:
|
|
self.uom_po_id = self.uom_id
|
|
|
|
@api.onchange('type')
|
|
def _onchange_type(self):
|
|
# Do nothing but needed for inheritance
|
|
return {}
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date'''
|
|
templates = super(ProductTemplate, self).create(vals_list)
|
|
if "create_product_product" not in self._context:
|
|
templates._create_variant_ids()
|
|
|
|
# This is needed to set given values to first variant after creation
|
|
for template, vals in zip(templates, vals_list):
|
|
related_vals = {}
|
|
if vals.get('barcode'):
|
|
related_vals['barcode'] = vals['barcode']
|
|
if vals.get('default_code'):
|
|
related_vals['default_code'] = vals['default_code']
|
|
if vals.get('standard_price'):
|
|
related_vals['standard_price'] = vals['standard_price']
|
|
if vals.get('volume'):
|
|
related_vals['volume'] = vals['volume']
|
|
if vals.get('weight'):
|
|
related_vals['weight'] = vals['weight']
|
|
# Please do forward port
|
|
if vals.get('packaging_ids'):
|
|
related_vals['packaging_ids'] = vals['packaging_ids']
|
|
if related_vals:
|
|
template.write(related_vals)
|
|
|
|
return templates
|
|
|
|
def write(self, vals):
|
|
if 'uom_id' in vals or 'uom_po_id' in vals:
|
|
uom_id = self.env['uom.uom'].browse(vals.get('uom_id')) or self.uom_id
|
|
uom_po_id = self.env['uom.uom'].browse(vals.get('uom_po_id')) or self.uom_po_id
|
|
if uom_id and uom_po_id and uom_id.category_id != uom_po_id.category_id:
|
|
vals['uom_po_id'] = uom_id.id
|
|
res = super(ProductTemplate, self).write(vals)
|
|
if 'attribute_line_ids' in vals or vals.get('active'):
|
|
self._create_variant_ids()
|
|
if 'active' in vals and not vals.get('active'):
|
|
self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')})
|
|
if 'image_1920' in vals:
|
|
self.env['product.product'].invalidate_cache(fnames=[
|
|
'image_1920',
|
|
'image_1024',
|
|
'image_512',
|
|
'image_256',
|
|
'image_128',
|
|
'can_image_1024_be_zoomed',
|
|
])
|
|
return res
|
|
|
|
@api.returns('self', lambda value: value.id)
|
|
def copy(self, default=None):
|
|
# TDE FIXME: should probably be copy_data
|
|
self.ensure_one()
|
|
if default is None:
|
|
default = {}
|
|
if 'name' not in default:
|
|
default['name'] = _("%s (copy)", self.name)
|
|
return super(ProductTemplate, self).copy(default=default)
|
|
|
|
def name_get(self):
|
|
# Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields
|
|
self.browse(self.ids).read(['name', 'default_code'])
|
|
return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name))
|
|
for template in self]
|
|
|
|
@api.model
|
|
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
|
|
# Only use the product.product heuristics if there is a search term and the domain
|
|
# does not specify a match on `product.template` IDs.
|
|
if not name or any(term[0] == 'id' for term in (args or [])):
|
|
return super(ProductTemplate, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
|
|
|
|
Product = self.env['product.product']
|
|
templates = self.browse([])
|
|
domain_no_variant = [('product_variant_ids', '=', False)]
|
|
while True:
|
|
domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or []
|
|
args = args if args is not None else []
|
|
products_ns = Product._name_search(name, args+domain, operator=operator, name_get_uid=name_get_uid)
|
|
products = Product.browse([x[0] for x in products_ns])
|
|
new_templates = products.mapped('product_tmpl_id')
|
|
if new_templates & templates:
|
|
"""Product._name_search can bypass the domain we passed (search on supplier info).
|
|
If this happens, an infinite loop will occur."""
|
|
break
|
|
templates |= new_templates
|
|
current_round_templates = self.browse([])
|
|
if not products:
|
|
domain_template = args + domain_no_variant + (templates and [('id', 'not in', templates.ids)] or [])
|
|
template_ns = super(ProductTemplate, self)._name_search(name=name, args=domain_template, operator=operator, limit=limit, name_get_uid=name_get_uid)
|
|
current_round_templates |= self.browse([ns[0] for ns in template_ns])
|
|
templates |= current_round_templates
|
|
if (not products and not current_round_templates) or (limit and (len(templates) > limit)):
|
|
break
|
|
|
|
searched_ids = set(templates.ids)
|
|
# some product.templates do not have product.products yet (dynamic variants configuration),
|
|
# we need to add the base _name_search to the results
|
|
# FIXME awa: this is really not performant at all but after discussing with the team
|
|
# we don't see another way to do it
|
|
if not limit or len(searched_ids) < limit:
|
|
searched_ids |= set([template_id[0] for template_id in
|
|
super(ProductTemplate, self)._name_search(
|
|
name,
|
|
args=args,
|
|
operator=operator,
|
|
limit=limit,
|
|
name_get_uid=name_get_uid)])
|
|
|
|
# re-apply product.template order + name_get
|
|
return super(ProductTemplate, self)._name_search(
|
|
'', args=[('id', 'in', list(searched_ids))],
|
|
operator='ilike', limit=limit, name_get_uid=name_get_uid)
|
|
|
|
def open_pricelist_rules(self):
|
|
self.ensure_one()
|
|
domain = ['|',
|
|
('product_tmpl_id', '=', self.id),
|
|
('product_id', 'in', self.product_variant_ids.ids)]
|
|
return {
|
|
'name': _('Price Rules'),
|
|
'view_mode': 'tree,form',
|
|
'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')],
|
|
'res_model': 'product.pricelist.item',
|
|
'type': 'ir.actions.act_window',
|
|
'target': 'current',
|
|
'domain': domain,
|
|
'context': {
|
|
'default_product_tmpl_id': self.id,
|
|
'default_applied_on': '1_product',
|
|
'product_without_variants': self.product_variant_count == 1,
|
|
},
|
|
}
|
|
|
|
def price_compute(self, price_type, uom=False, currency=False, company=None):
|
|
# TDE FIXME: delegate to template or not ? fields are reencoded here ...
|
|
# compatibility about context keys used a bit everywhere in the code
|
|
if not uom and self._context.get('uom'):
|
|
uom = self.env['uom.uom'].browse(self._context['uom'])
|
|
if not currency and self._context.get('currency'):
|
|
currency = self.env['res.currency'].browse(self._context['currency'])
|
|
|
|
templates = self
|
|
if price_type == 'standard_price':
|
|
# standard_price field can only be seen by users in base.group_user
|
|
# Thus, in order to compute the sale price from the cost for users not in this group
|
|
# We fetch the standard price as the superuser
|
|
templates = self.with_company(company).sudo()
|
|
if not company:
|
|
company = self.env.company
|
|
date = self.env.context.get('date') or fields.Date.today()
|
|
|
|
prices = dict.fromkeys(self.ids, 0.0)
|
|
for template in templates:
|
|
prices[template.id] = template[price_type] or 0.0
|
|
# yes, there can be attribute values for product template if it's not a variant YET
|
|
# (see field product.attribute create_variant)
|
|
if price_type == 'list_price' and self._context.get('current_attributes_price_extra'):
|
|
# we have a list of price_extra that comes from the attribute values, we need to sum all that
|
|
prices[template.id] += sum(self._context.get('current_attributes_price_extra'))
|
|
|
|
if uom:
|
|
prices[template.id] = template.uom_id._compute_price(prices[template.id], uom)
|
|
|
|
# Convert from current user company currency to asked one
|
|
# This is right cause a field cannot be in more than one currency
|
|
if currency:
|
|
prices[template.id] = template.currency_id._convert(prices[template.id], currency, company, date)
|
|
|
|
return prices
|
|
|
|
def _create_variant_ids(self):
|
|
self.flush()
|
|
Product = self.env["product.product"]
|
|
|
|
variants_to_create = []
|
|
variants_to_activate = Product
|
|
variants_to_unlink = Product
|
|
|
|
for tmpl_id in self:
|
|
lines_without_no_variants = tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes()
|
|
|
|
all_variants = tmpl_id.with_context(active_test=False).product_variant_ids.sorted('active')
|
|
|
|
current_variants_to_create = []
|
|
current_variants_to_activate = 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
|
|
single_value_lines = lines_without_no_variants.filtered(lambda ptal: len(ptal.product_template_value_ids._only_active()) == 1)
|
|
if single_value_lines:
|
|
for variant in all_variants:
|
|
combination = variant.product_template_attribute_value_ids | single_value_lines.product_template_value_ids._only_active()
|
|
# Do not add single value if the resulting combination would
|
|
# be invalid anyway.
|
|
if (
|
|
len(combination) == len(lines_without_no_variants) and
|
|
combination.attribute_line_id == lines_without_no_variants
|
|
):
|
|
variant.product_template_attribute_value_ids = combination
|
|
|
|
# Determine which product variants need to be created based on the attribute
|
|
# configuration. If any attribute is set to generate variants dynamically, skip the
|
|
# 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 tmpl_id.has_dynamic_attributes():
|
|
# Iterator containing all possible `product.template.attribute.value` combination
|
|
# The iterator is used to avoid MemoryError in case of a huge number of combination.
|
|
all_combinations = itertools.product(*[
|
|
ptal.product_template_value_ids._only_active() for ptal in lines_without_no_variants
|
|
])
|
|
# Set containing existing `product.template.attribute.value` combination
|
|
existing_variants = {
|
|
variant.product_template_attribute_value_ids: variant for variant in all_variants
|
|
}
|
|
# For each possible variant, create if it doesn't exist yet.
|
|
for combination_tuple in all_combinations:
|
|
combination = self.env['product.template.attribute.value'].concat(*combination_tuple)
|
|
if combination in existing_variants:
|
|
current_variants_to_activate += existing_variants[combination]
|
|
else:
|
|
current_variants_to_create.append({
|
|
'product_tmpl_id': tmpl_id.id,
|
|
'product_template_attribute_value_ids': [(6, 0, combination.ids)],
|
|
'active': tmpl_id.active,
|
|
})
|
|
if len(current_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*.'))
|
|
variants_to_create += current_variants_to_create
|
|
variants_to_activate += current_variants_to_activate
|
|
|
|
variants_to_unlink += all_variants - current_variants_to_activate
|
|
|
|
if variants_to_activate:
|
|
variants_to_activate.write({'active': True})
|
|
if variants_to_create:
|
|
Product.create(variants_to_create)
|
|
if variants_to_unlink:
|
|
variants_to_unlink._unlink_or_archive()
|
|
|
|
# prefetched o2m have to be reloaded (because of active_test)
|
|
# (eg. product.template: product_variant_ids)
|
|
# We can't rely on existing invalidate_cache because of the savepoint
|
|
# in _unlink_or_archive.
|
|
self.flush()
|
|
self.invalidate_cache()
|
|
return True
|
|
|
|
def has_dynamic_attributes(self):
|
|
"""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.valid_product_template_attribute_line_ids.attribute_id)
|
|
|
|
@api.depends('attribute_line_ids.value_ids')
|
|
def _compute_valid_product_template_attribute_line_ids(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.
|
|
"""
|
|
for record in self:
|
|
record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
|
|
|
|
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.
|
|
If there are a lot of variants, this method might be slow. Even if there
|
|
aren't too many variants, for performance reasons, do not call this
|
|
method in a loop over the product templates.
|
|
|
|
Therefore this method has a very restricted reasonable use case and you
|
|
should strongly consider doing things differently if you consider using
|
|
this method.
|
|
|
|
: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))
|
|
|
|
def _get_attribute_exclusions(self, parent_combination=None, parent_name=None):
|
|
"""Return the list of attribute exclusions of a product.
|
|
|
|
: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`
|
|
:param parent_name: the name of the parent product combination.
|
|
:type parent_name: str
|
|
|
|
:return: dict of exclusions
|
|
- exclusions: from this product itself
|
|
- parent_combination: ids of the given parent_combination
|
|
- parent_exclusions: from the parent_combination
|
|
- parent_product_name: the name of the parent product if any, used in the interface
|
|
to explain why some combinations are not available.
|
|
(e.g: Not available with Customizable Desk (Legs: Steel))
|
|
- mapped_attribute_names: the name of every attribute values based on their id,
|
|
used to explain in the interface why that combination is not available
|
|
(e.g: Not available with Color: Black)
|
|
"""
|
|
self.ensure_one()
|
|
parent_combination = parent_combination or self.env['product.template.attribute.value']
|
|
return {
|
|
'exclusions': self._complete_inverse_exclusions(self._get_own_attribute_exclusions()),
|
|
'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination),
|
|
'parent_combination': parent_combination.ids,
|
|
'parent_product_name': parent_name,
|
|
'mapped_attribute_names': self._get_mapped_attribute_names(parent_combination),
|
|
}
|
|
|
|
@api.model
|
|
def _complete_inverse_exclusions(self, exclusions):
|
|
"""Will complete the dictionnary of exclusions with their respective inverse
|
|
e.g: Black excludes XL and L
|
|
-> XL excludes Black
|
|
-> L excludes Black"""
|
|
result = dict(exclusions)
|
|
for key, value in exclusions.items():
|
|
for exclusion in value:
|
|
if exclusion in result and key not in result[exclusion]:
|
|
result[exclusion].append(key)
|
|
else:
|
|
result[exclusion] = [key]
|
|
|
|
return result
|
|
|
|
def _get_own_attribute_exclusions(self):
|
|
"""Get exclusions coming from the current template.
|
|
|
|
Dictionnary, each product template attribute value 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.valid_product_template_attribute_line_ids.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
|
|
}
|
|
|
|
def _get_parent_attribute_exclusions(self, parent_combination):
|
|
"""Get exclusions coming from the parent combination.
|
|
|
|
Dictionnary, each parent's ptav is a key, and for each of them the value is
|
|
an array with the other ptav that are excluded because of the parent.
|
|
"""
|
|
self.ensure_one()
|
|
if not parent_combination:
|
|
return {}
|
|
|
|
result = {}
|
|
for product_attribute_value in parent_combination:
|
|
for filter_line in product_attribute_value.exclude_for.filtered(
|
|
lambda filter_line: filter_line.product_tmpl_id == self
|
|
):
|
|
# Some exclusions don't have attribute value. This means that the template is not
|
|
# compatible with the parent combination. If such an exclusion is found, it means that all
|
|
# attribute values are excluded.
|
|
if filter_line.value_ids:
|
|
result[product_attribute_value.id] = filter_line.value_ids.ids
|
|
else:
|
|
result[product_attribute_value.id] = filter_line.product_tmpl_id.mapped('attribute_line_ids.product_template_value_ids').ids
|
|
|
|
return result
|
|
|
|
def _get_mapped_attribute_names(self, parent_combination=None):
|
|
""" The name of every attribute values based on their id,
|
|
used to explain in the interface why that combination is not available
|
|
(e.g: Not available with Color: Black).
|
|
|
|
It contains both attribute value names from this product and from
|
|
the parent combination if provided.
|
|
"""
|
|
self.ensure_one()
|
|
all_product_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids
|
|
if parent_combination:
|
|
all_product_attribute_values |= parent_combination
|
|
|
|
return {
|
|
attribute_value.id: attribute_value.display_name
|
|
for attribute_value in all_product_attribute_values
|
|
}
|
|
|
|
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.valid_product_template_attribute_line_ids):
|
|
# number of attribute values passed is different than the
|
|
# configuration of attributes on the template
|
|
return False
|
|
|
|
if self.valid_product_template_attribute_line_ids != combination.attribute_line_id:
|
|
# combination has different attributes than the ones configured on the template
|
|
return False
|
|
|
|
if self.valid_product_template_attribute_line_ids.product_template_value_ids._only_active() < combination:
|
|
# combination has different values than the ones configured on the template
|
|
return False
|
|
|
|
variant = self._get_variant_for_combination(combination)
|
|
|
|
if self.has_dynamic_attributes():
|
|
if variant and not variant.active:
|
|
# dynamic and the variant has been archived
|
|
return False
|
|
else:
|
|
if not variant or not variant.active:
|
|
# not dynamic, the variant has been archived or 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:
|
|
# parent_exclusion are mapped by ptav but here we don't need to know
|
|
# where the exclusion comes from so we loop directly on the dict values
|
|
for exclusions_values in parent_exclusions.values():
|
|
for exclusion in exclusions_values:
|
|
if exclusion in combination.ids:
|
|
return False
|
|
|
|
return True
|
|
|
|
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()
|
|
return self.env['product.product'].browse(self._get_variant_id_for_combination(filtered_combination))
|
|
|
|
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.
|
|
If we are in this case and the variant already exists but it is
|
|
archived, it is activated instead of being created again.
|
|
|
|
: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()
|
|
|
|
Product = self.env['product.product']
|
|
|
|
product_variant = self._get_variant_for_combination(combination)
|
|
if product_variant:
|
|
if not product_variant.active and self.has_dynamic_attributes() and self._is_combination_possible(combination):
|
|
product_variant.active = True
|
|
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
|
|
|
|
return Product.sudo().create({
|
|
'product_tmpl_id': self.id,
|
|
'product_template_attribute_value_ids': [(6, 0, combination._without_no_variant_attributes().ids)]
|
|
})
|
|
|
|
@tools.ormcache('self.id', 'frozenset(filtered_combination.ids)')
|
|
def _get_variant_id_for_combination(self, filtered_combination):
|
|
"""See `_get_variant_for_combination`. This method returns an ID
|
|
so it can be cached.
|
|
|
|
Use sudo because the same result should be cached for all users.
|
|
"""
|
|
self.ensure_one()
|
|
domain = [('product_tmpl_id', '=', self.id)]
|
|
combination_indices_ids = filtered_combination._ids2str()
|
|
|
|
if combination_indices_ids:
|
|
domain = expression.AND([domain, [('combination_indices', '=', combination_indices_ids)]])
|
|
else:
|
|
domain = expression.AND([domain, [('combination_indices', 'in', ['', False])]])
|
|
|
|
return self.env['product.product'].sudo().with_context(active_test=False).search(domain, order='active DESC', limit=1).id
|
|
|
|
@tools.ormcache('self.id')
|
|
def _get_first_possible_variant_id(self):
|
|
"""See `_create_first_product_variant`. This method returns an ID
|
|
so it can be cached."""
|
|
self.ensure_one()
|
|
return self._create_first_product_variant().id
|
|
|
|
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
|
|
"""See `_get_possible_combinations` (one iteration).
|
|
|
|
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.
|
|
"""
|
|
return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
|
|
|
|
def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
|
|
"""Generator returning combinations that are possible, following the
|
|
sequence of attributes and values.
|
|
|
|
See `_is_combination_possible` for what is a possible combination.
|
|
|
|
When encountering an impossible combination, try to change the value
|
|
of attributes by starting with the further regarding their sequences.
|
|
|
|
Ignore attributes that have no values.
|
|
|
|
: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 possible combinations
|
|
:rtype: generator of recordset of `product.template.attribute.value`
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if not self.active:
|
|
return _("The product template is archived so no combination is possible.")
|
|
|
|
necessary_values = necessary_values or self.env['product.template.attribute.value']
|
|
necessary_attributes = necessary_values.mapped('attribute_id')
|
|
ptal_stack = [self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal.attribute_id not in necessary_attributes)]
|
|
combination_stack = [necessary_values]
|
|
|
|
# keep going while we have attribute lines to test
|
|
while len(ptal_stack):
|
|
attribute_lines = ptal_stack.pop()
|
|
combination = combination_stack.pop()
|
|
|
|
if not attribute_lines:
|
|
# full combination, if it's possible return it, otherwise skip it
|
|
if self._is_combination_possible(combination, parent_combination):
|
|
yield(combination)
|
|
else:
|
|
# we have remaining attribute lines to consider
|
|
for ptav in reversed(attribute_lines[0].product_template_value_ids._only_active()):
|
|
ptal_stack.append(attribute_lines[1:])
|
|
combination_stack.append(combination + ptav)
|
|
|
|
return _("There are no remaining possible combination.")
|
|
|
|
def _get_closest_possible_combination(self, combination):
|
|
"""See `_get_closest_possible_combinations` (one iteration).
|
|
|
|
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.
|
|
"""
|
|
return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
|
|
|
|
def _get_closest_possible_combinations(self, combination):
|
|
"""Generator returning the possible combinations that are 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.
|
|
|
|
:param combination: the values to include if they are possible
|
|
:type combination: recordset `product.template.attribute.value`
|
|
|
|
:return: the possible combinations that are including as much
|
|
elements as possible from the given combination.
|
|
:rtype: generator of recordset of product.template.attribute.value
|
|
"""
|
|
while True:
|
|
res = self._get_possible_combinations(necessary_values=combination)
|
|
try:
|
|
# If there is at least one result for the given combination
|
|
# we consider that combination set, and we yield all the
|
|
# possible combinations for it.
|
|
yield(next(res))
|
|
for cur in res:
|
|
yield(cur)
|
|
return _("There are no remaining closest combination.")
|
|
except StopIteration:
|
|
# There are no results for the given combination, we try to
|
|
# progressively remove values from it.
|
|
if not combination:
|
|
return _("There are no possible combination.")
|
|
combination = combination[:-1]
|
|
|
|
def _get_current_company(self, **kwargs):
|
|
"""Get the most appropriate company for this product.
|
|
|
|
If the company is set on the product, directly return it. Otherwise,
|
|
fallback to a contextual company.
|
|
|
|
:param kwargs: kwargs forwarded to the fallback method.
|
|
|
|
:return: the most appropriate company for this product
|
|
:rtype: recordset of one `res.company`
|
|
"""
|
|
self.ensure_one()
|
|
return self.company_id or self._get_current_company_fallback(**kwargs)
|
|
|
|
def _get_current_company_fallback(self, **kwargs):
|
|
"""Fallback to get the most appropriate company for this product.
|
|
|
|
This should only be called from `_get_current_company` but is defined
|
|
separately to allow override.
|
|
|
|
The final fallback will be the current user's company.
|
|
|
|
:return: the fallback company for this product
|
|
:rtype: recordset of one `res.company`
|
|
"""
|
|
self.ensure_one()
|
|
return self.env.company
|
|
|
|
def get_single_product_variant(self):
|
|
""" Method used by the product configurator to check if the product is configurable or not.
|
|
|
|
We need to open the product configurator if the product:
|
|
- is configurable (see has_configurable_attributes)
|
|
- has optional products (method is extended in sale to return optional products info)
|
|
"""
|
|
self.ensure_one()
|
|
if self.product_variant_count == 1 and not self.has_configurable_attributes:
|
|
return {
|
|
'product_id': self.product_variant_id.id,
|
|
}
|
|
return {}
|
|
|
|
@api.model
|
|
def get_empty_list_help(self, help):
|
|
self = self.with_context(
|
|
empty_list_help_document_name=_("product"),
|
|
)
|
|
return super(ProductTemplate, self).get_empty_list_help(help)
|
|
|
|
@api.model
|
|
def get_import_templates(self):
|
|
return [{
|
|
'label': _('Import Template for Products'),
|
|
'template': '/product/static/xls/product_template.xls'
|
|
}]
|