Files
odoo_source/addons/product/models/product_template.py
T
Aurélien Warnon 783e884ddc [IMP] product,(website_)sale: Fix parent exclusions - product configurator
Targets commit d3530eb07e

Purpose
=======
- Exclusions for optional/accessory products do not work for attributes configured as "never create variants".
  These attributes are now added to the reference_product context so that they can be taken into account when
  evaluating exclusions.
2018-10-02 10:17:59 +02:00

576 lines
29 KiB
Python

# -*- coding: utf-8 -*-
# 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.tools import pycompat
from odoo.exceptions import UserError
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 = fields.Float(
'Price', compute='_compute_template_price', inverse='_set_template_price',
digits=dp.get_precision('Product Price'))
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 = 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):
try:
main_company = self.sudo().env.ref('base.main_company')
except ValueError:
main_company = self.env['res.company'].sudo().search([], limit=1, order="id")
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._context.get('partner')
quantity = self._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):
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"]
AttributeValues = self.env['product.attribute.value']
variants_to_create = []
variants_to_activate = []
variants_to_unlink = []
for tmpl_id in self.with_context(active_test=False):
# 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')
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)]})
# iterator of n-uple of product.attribute.value *ids*
variant_matrix = [
AttributeValues.browse(value_ids)
for value_ids in 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'))
]
# get the value (id) sets of existing variants
existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids) for variant in tmpl_id.product_variant_ids}
# -> for each value set, create a recordset of values to create a
# variant for if the value set isn't already a variant
for value_ids in variant_matrix:
if set(value_ids.ids) not in existing_variants and not any(value_id.attribute_id.create_variant == 'dynamic' for value_id in value_ids):
variants_to_create.append({
'product_tmpl_id': tmpl_id.id,
'attribute_value_ids': [(6, 0, value_ids.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 product
for product_id in tmpl_id.product_variant_ids:
if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') in variant_matrix:
variants_to_activate.append(product_id)
elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') not in variant_matrix:
variants_to_unlink.append(product_id)
if variants_to_activate:
Product.concat(*variants_to_activate).write({'active': True})
# create new products
if variants_to_create:
Product.create(variants_to_create)
# unlink or inactive product
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 (psycopg2.Error, except_orm):
variant.write({'active': False})
pass
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'))
@api.multi
def get_filtered_variants(self, reference_product=None):
"""
Will filter availability (excluded) for the product
combinations (ex: color: white excludes size: large).
Will also filter availability (excluded) for the parent
product if specified (meaning that this product is a an optionnal or
accessory product of the reference_product).
Args:
reference_product (product.product): The reference product that has
the current product as an option or accessory product.
Returns:
The filtered list of product variants
"""
self.ensure_one()
product_template_attribute_values = self.env['product.template.attribute.value'].search([('product_tmpl_id', '=', self.id)])
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'):
# 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')
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']
# 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))
return product_variants
@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'
}]