Files
odoo_source/addons/product/models/product_template.py
T
Sébastien Theys 82ea843585 [FIX] base,product,website_sale: default to main company currency
When using multi-company, the base currency of a product is the currency of the
main company, not a contextual company.

closes #29905
closes #29946
closes #30046
2019-01-09 16:19:45 +00:00

952 lines
44 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
from odoo.addons import decimal_precision as dp
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError, RedirectWarning, UserError
from odoo.osv import expression
from odoo.tools import pycompat
class ProductTemplate(models.Model):
_name = "product.template"
_inherit = ['mail.thread', 'mail.activity.mixin']
_description = "Product Template"
_order = "name"
def _get_default_category_id(self):
if self._context.get('categ_id') or self._context.get('default_categ_id'):
return self._context.get('categ_id') or self._context.get('default_categ_id')
category = self.env.ref('product.product_category_all', raise_if_not_found=False)
if not category:
category = self.env['product.category'].search([], limit=1)
if category:
return category.id
else:
err_msg = _('You must define at least one product category in order to be able to create products.')
redir_msg = _('Go to Internal Categories')
raise RedirectWarning(err_msg, self.env.ref('product.product_category_action_form').id, redir_msg)
def _get_default_uom_id(self):
return self.env["uom.uom"].search([], limit=1, order='id').id
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(
'Sale 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.')
rental = fields.Boolean('Can be Rent')
categ_id = fields.Many2one(
'product.category', 'Product Category',
change_default=True, default=_get_default_category_id,
required=True, help="Select category for the current product")
currency_id = fields.Many2one(
'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'))
standard_price = fields.Float(
'Cost', compute='_compute_standard_price',
inverse='_set_standard_price', search='_search_standard_price',
digits=dp.get_precision('Product Price'), groups="base.group_user",
help = "Cost used for stock valuation in standard price and as a first price to set in average/FIFO.")
volume = fields.Float(
'Volume', compute='_compute_volume', inverse='_set_volume',
help="The volume in m3.", store=True)
weight = fields.Float(
'Weight', compute='_compute_weight', digits=dp.get_precision('Stock Weight'),
inverse='_set_weight', store=True,
help="The weight of the contents in Kg, not including any packaging, etc.")
weight_uom_id = fields.Many2one('uom.uom', string='Weight Unit of Measure', compute='_compute_weight_uom_id')
weight_uom_name = fields.Char(string='Weight unit of measure label', related='weight_uom_id.name', readonly=True)
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',
default=lambda self: self.env['res.company']._company_default_get('product.template'), 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')
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', oldname='ean13', related='product_variant_ids.barcode', readonly=False)
default_code = fields.Char(
'Internal Reference', compute='_compute_default_code',
inverse='_set_default_code', store=True)
item_ids = fields.One2many('product.pricelist.item', 'product_tmpl_id', 'Pricelist Items')
# image: all image fields are base64 encoded and PIL-supported
image = fields.Binary(
"Image", attachment=True,
help="This field holds the image used as image for the product, limited to 1024x1024px.")
image_medium = fields.Binary(
"Medium-sized image", attachment=True,
help="Medium-sized image of the product. It is automatically "
"resized as a 128x128px image, with aspect ratio preserved, "
"only when the image exceeds one of those sizes. Use this field in form views or some kanban views.")
image_small = fields.Binary(
"Small-sized image", attachment=True,
help="Small-sized image of the product. It is automatically "
"resized as a 64x64px image, with aspect ratio preserved. "
"Use this field anywhere a small image is required.")
@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.multi
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.multi
def _compute_template_price(self):
prices = self._compute_template_price_no_inverse()
for template in self:
template.price = prices.get(template.id, 0.0)
@api.multi
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 display_name or ID for compatibility
if isinstance(pricelist_id_or_name, pycompat.string_types):
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, pycompat.integer_types):
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
@api.multi
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('product_variant_ids', 'product_variant_ids.standard_price')
def _compute_standard_price(self):
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
@api.one
def _set_standard_price(self):
if len(self.product_variant_ids) == 1:
self.product_variant_ids.standard_price = self.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
@api.one
def _set_volume(self):
if len(self.product_variant_ids) == 1:
self.product_variant_ids.volume = self.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):
for template in self:
template.is_product_variant = False
@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.
"""
get_param = self.env['ir.config_parameter'].sudo().get_param
product_weight_in_lbs_param = get_param('product.weight_in_lbs')
if product_weight_in_lbs_param == '1':
return self.env.ref('uom.product_uom_lb')
else:
return self.env.ref('uom.product_uom_kgm')
def _compute_weight_uom_id(self):
weight_uom_id = self._get_weight_uom_id_from_ir_config_parameter()
for product_template in self:
product_template.weight_uom_id = weight_uom_id
@api.one
def _set_weight(self):
if len(self.product_variant_ids) == 1:
self.product_variant_ids.weight = self.weight
@api.one
@api.depends('product_variant_ids.product_tmpl_id')
def _compute_product_variant_count(self):
# do not pollute variants to be prefetched when counting variants
self.product_variant_count = len(self.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 = ''
@api.one
def _set_default_code(self):
if len(self.product_variant_ids) == 1:
self.product_variant_ids.default_code = self.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
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.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'''
# TDE FIXME: context brol
for vals in vals_list:
tools.image_resize_images(vals)
templates = super(ProductTemplate, self).create(vals_list)
if "create_product_product" not in self._context:
templates.with_context(create_from_tmpl=True).create_variant_ids()
# This is needed to set given values to first variant after creation
for template, vals in pycompat.izip(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']
if related_vals:
template.write(related_vals)
return templates
@api.multi
def write(self, vals):
tools.image_resize_images(vals)
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')})
return res
@api.multi
@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)
@api.multi
def name_get(self):
# Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields
self.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([])
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])
templates |= products.mapped('product_tmpl_id')
if (not products) or (limit and (len(templates) > limit)):
break
# re-apply product.template order + name_get
return super(ProductTemplate, self)._name_search(
'', args=[('id', 'in', list(set(templates.ids)))],
operator='ilike', limit=limit, name_get_uid=name_get_uid)
@api.multi
def price_compute(self, price_type, uom=False, currency=False, company=False):
# 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_context(force_company=company and company.id or self._context.get('force_company', self.env.user.company_id.id)).sudo()
if not company:
if self._context.get('force_company'):
company = self.env['res.company'].browse(self._context['force_company'])
else:
company = self.env.user.company_id
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
# compatibility to remove after v10 - DEPRECATED
@api.model
def _price_get(self, products, ptype='list_price'):
return products.price_compute(ptype)
@api.multi
def create_variant_ids(self):
Product = self.env["product.product"]
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._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)]})
# 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.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._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
))
# Set containing existing `product.attribute.value` combination
existing_variants = {
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.
for value_ids in all_variants:
value_ids = frozenset(value_ids)
if value_ids not in existing_variants:
variants_to_create.append({
'product_tmpl_id': tmpl_id.id,
'attribute_value_ids': [(6, 0, list(value_ids))],
})
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*.'))
# 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._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 += product_id
else:
variants_to_unlink += product_id
if variants_to_activate:
variants_to_activate.write({'active': True})
# create new products
if variants_to_create:
Product.create(variants_to_create)
# unlink or inactive product
# try in batch first because it is much faster
try:
with self._cr.savepoint(), tools.mute_logger('odoo.sql_db'):
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 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):
"""deprecated, use _get_possible_variants instead"""
self.ensure_one()
parent_combination = self.env['product.template.attribute.value']
if reference_product:
# append the reference_product if provided
parent_combination |= reference_product.product_template_attribute_value_ids
if reference_product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
parent_combination |= reference_product.env.context.get('no_variant_attribute_values')
return self._get_possible_variants(parent_combination)
@api.multi
def _get_attribute_exclusions(self, parent_combination=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`
: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.multi
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)
@api.multi
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.user.company_id
@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'
}]