diff options
Diffstat (limited to 'addons/product/models/product_template.py')
| -rw-r--r-- | addons/product/models/product_template.py | 1239 |
1 files changed, 1239 insertions, 0 deletions
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py new file mode 100644 index 00000000..b75909f9 --- /dev/null +++ b/addons/product/models/product_template.py @@ -0,0 +1,1239 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import itertools +import logging +from collections import defaultdict + +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', depends_context=('company',), 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') + else: + return self.env.ref('uom.product_uom_kgm') + + @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') and len(self.product_variant_ids) == 0): + 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_ids = Product._name_search(name, args+domain, operator=operator, name_get_uid=name_get_uid) + products = Product.browse(products_ids) + 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_ids = 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(template_ids) + 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(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(lambda p: (p.active, -p.id)) + + 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 + + # Set containing existing `product.template.attribute.value` combination + existing_variants = { + variant.product_template_attribute_value_ids: variant for variant in all_variants + } + + # 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 + ]) + # 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 + + else: + for variant in existing_variants.values(): + is_combination_possible = self._is_combination_possible_by_config( + combination=variant.product_template_attribute_value_ids, + ignore_no_variant=True, + ) + if is_combination_possible: + current_variants_to_activate += variant + 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. + + 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_by_config(self, combination, ignore_no_variant=False): + """Return whether the given combination is possible according to the config of attributes on the template + + :param combination: the combination to check for possibility + :type combination: recordset `product.template.attribute.value` + + :param ignore_no_variant: whether no_variant attributes should be ignored + :type ignore_no_variant: bool + + :return: wether the given combination is possible according to the config of attributes on the template + :rtype: bool + """ + self.ensure_one() + + attribute_lines = self.valid_product_template_attribute_line_ids + + if ignore_no_variant: + attribute_lines = attribute_lines._without_no_variant_attributes() + + if len(combination) != len(attribute_lines): + # number of attribute values passed is different than the + # configuration of attributes on the template + return False + + if attribute_lines != combination.attribute_line_id: + # combination has different attributes than the ones configured on the template + return False + + if not (attribute_lines.product_template_value_ids._only_active() >= combination): + # combination has different values than the ones configured on the template + return False + + return True + + def _is_combination_possible(self, combination, parent_combination=None, ignore_no_variant=False): + """ + 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 ignore_no_variant: whether no_variant attributes should be ignored + :type ignore_no_variant: bool + + :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 not self._is_combination_possible_by_config(combination, ignore_no_variant): + 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 _cartesian_product(self, product_template_attribute_values_per_line, parent_combination): + """ + Generate all possible combination for attributes values (aka cartesian product). + It is equivalent to itertools.product except it skips invalid partial combinations before they are complete. + + Imagine the cartesian product of 'A', 'CD' and range(1_000_000) and let's say that 'A' and 'C' are incompatible. + If you use itertools.product or any normal cartesian product, you'll need to filter out of the final result + the 1_000_000 combinations that start with 'A' and 'C' . Instead, This implementation will test if 'A' and 'C' are + compatible before even considering range(1_000_000), skip it and and continue with combinations that start + with 'A' and 'D'. + + It's necessary for performance reason because filtering out invalid combinations from standard Cartesian product + can be extremely slow + + :param product_template_attribute_values_per_line: the values we want all the possibles combinations of. + One list of values by attribute line + :return: a generator of product template attribute value + """ + if not product_template_attribute_values_per_line: + return + + all_exclusions = {self.env['product.template.attribute.value'].browse(k): + self.env['product.template.attribute.value'].browse(v) for k, v in + self._get_own_attribute_exclusions().items()} + # The following dict uses product template attribute values as keys + # 0 means the value is acceptable, greater than 0 means it's rejected, it cannot be negative + # Bear in mind that several values can reject the same value and the latter can only be included in the + # considered combination if no value rejects it. + # This dictionary counts how many times each value is rejected. + # Each time a value is included in the considered combination, the values it rejects are incremented + # When a value is discarded from the considered combination, the values it rejects are decremented + current_exclusions = defaultdict(int) + for exclusion in self._get_parent_attribute_exclusions(parent_combination): + current_exclusions[self.env['product.template.attribute.value'].browse(exclusion)] += 1 + partial_combination = self.env['product.template.attribute.value'] + + # The following list reflects product_template_attribute_values_per_line + # For each line, instead of a list of values, it contains the index of the selected value + # -1 means no value has been picked for the line in the current (partial) combination + value_index_per_line = [-1] * len(product_template_attribute_values_per_line) + # determines which line line we're working on + line_index = 0 + + while True: + current_line_values = product_template_attribute_values_per_line[line_index] + current_ptav_index = value_index_per_line[line_index] + current_ptav = current_line_values[current_ptav_index] + + # removing exclusions from current_ptav as we're removing it from partial_combination + if current_ptav_index >= 0: + for ptav_to_include_back in all_exclusions[current_ptav]: + current_exclusions[ptav_to_include_back] -= 1 + partial_combination -= current_ptav + + if current_ptav_index < len(current_line_values) - 1: + # go to next value of current line + value_index_per_line[line_index] += 1 + current_line_values = product_template_attribute_values_per_line[line_index] + current_ptav_index = value_index_per_line[line_index] + current_ptav = current_line_values[current_ptav_index] + elif line_index != 0: + # reset current line, and then go to previous line + value_index_per_line[line_index] = - 1 + line_index -= 1 + continue + else: + # we're done if we must reset first line + break + + # adding exclusions from current_ptav as we're incorporating it in partial_combination + for ptav_to_exclude in all_exclusions[current_ptav]: + current_exclusions[ptav_to_exclude] += 1 + partial_combination += current_ptav + + # test if included values excludes current value or if current value exclude included values + if current_exclusions[current_ptav] or \ + any(intersection in partial_combination for intersection in all_exclusions[current_ptav]): + continue + + if line_index == len(product_template_attribute_values_per_line) - 1: + # submit combination if we're on the last line + yield partial_combination + else: + # else we go to the next line + line_index += 1 + + 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_attribute_lines = necessary_values.mapped('attribute_line_id') + attribute_lines = self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal not in necessary_attribute_lines) + + if not attribute_lines and self._is_combination_possible(necessary_values, parent_combination): + yield necessary_values + + product_template_attribute_values_per_line = [ + ptal.product_template_value_ids._only_active() + for ptal in attribute_lines + ] + + for partial_combination in self._cartesian_product(product_template_attribute_values_per_line, parent_combination): + combination = partial_combination + necessary_values + if self._is_combination_possible(combination, parent_combination): + yield combination + + 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' + }] |
