1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import itertools 5import logging 6from collections import defaultdict 7 8from odoo import api, fields, models, tools, _, SUPERUSER_ID 9from odoo.exceptions import ValidationError, RedirectWarning, UserError 10from odoo.osv import expression 11 12_logger = logging.getLogger(__name__) 13 14 15class ProductTemplate(models.Model): 16 _name = "product.template" 17 _inherit = ['mail.thread', 'mail.activity.mixin', 'image.mixin'] 18 _description = "Product Template" 19 _order = "name" 20 21 @tools.ormcache() 22 def _get_default_category_id(self): 23 # Deletion forbidden (at least through unlink) 24 return self.env.ref('product.product_category_all') 25 26 @tools.ormcache() 27 def _get_default_uom_id(self): 28 # Deletion forbidden (at least through unlink) 29 return self.env.ref('uom.product_uom_unit') 30 31 def _read_group_categ_id(self, categories, domain, order): 32 category_ids = self.env.context.get('default_categ_id') 33 if not category_ids and self.env.context.get('group_expand'): 34 category_ids = categories._search([], order=order, access_rights_uid=SUPERUSER_ID) 35 return categories.browse(category_ids) 36 37 name = fields.Char('Name', index=True, required=True, translate=True) 38 sequence = fields.Integer('Sequence', default=1, help='Gives the sequence order when displaying a product list') 39 description = fields.Text( 40 'Description', translate=True) 41 description_purchase = fields.Text( 42 'Purchase Description', translate=True) 43 description_sale = fields.Text( 44 'Sales Description', translate=True, 45 help="A description of the Product that you want to communicate to your customers. " 46 "This description will be copied to every Sales Order, Delivery Order and Customer Invoice/Credit Note") 47 type = fields.Selection([ 48 ('consu', 'Consumable'), 49 ('service', 'Service')], string='Product Type', default='consu', required=True, 50 help='A storable product is a product for which you manage stock. The Inventory app has to be installed.\n' 51 'A consumable product is a product for which stock is not managed.\n' 52 'A service is a non-material product you provide.') 53 categ_id = fields.Many2one( 54 'product.category', 'Product Category', 55 change_default=True, default=_get_default_category_id, group_expand='_read_group_categ_id', 56 required=True, help="Select category for the current product") 57 58 currency_id = fields.Many2one( 59 'res.currency', 'Currency', compute='_compute_currency_id') 60 cost_currency_id = fields.Many2one( 61 'res.currency', 'Cost Currency', compute='_compute_cost_currency_id') 62 63 # price fields 64 # price: total template price, context dependent (partner, pricelist, quantity) 65 price = fields.Float( 66 'Price', compute='_compute_template_price', inverse='_set_template_price', 67 digits='Product Price') 68 # list_price: catalog price, user defined 69 list_price = fields.Float( 70 'Sales Price', default=1.0, 71 digits='Product Price', 72 help="Price at which the product is sold to customers.") 73 # lst_price: catalog price for template, but including extra for variants 74 lst_price = fields.Float( 75 'Public Price', related='list_price', readonly=False, 76 digits='Product Price') 77 standard_price = fields.Float( 78 'Cost', compute='_compute_standard_price', 79 inverse='_set_standard_price', search='_search_standard_price', 80 digits='Product Price', groups="base.group_user", 81 help="""In Standard Price & AVCO: value of the product (automatically computed in AVCO). 82 In FIFO: value of the last unit that left the stock (automatically computed). 83 Used to value the product when the purchase cost is not known (e.g. inventory adjustment). 84 Used to compute margins on sale orders.""") 85 86 volume = fields.Float( 87 'Volume', compute='_compute_volume', inverse='_set_volume', digits='Volume', store=True) 88 volume_uom_name = fields.Char(string='Volume unit of measure label', compute='_compute_volume_uom_name') 89 weight = fields.Float( 90 'Weight', compute='_compute_weight', digits='Stock Weight', 91 inverse='_set_weight', store=True) 92 weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name') 93 94 sale_ok = fields.Boolean('Can be Sold', default=True) 95 purchase_ok = fields.Boolean('Can be Purchased', default=True) 96 pricelist_id = fields.Many2one( 97 'product.pricelist', 'Pricelist', store=False, 98 help='Technical field. Used for searching on pricelists, not stored in database.') 99 uom_id = fields.Many2one( 100 'uom.uom', 'Unit of Measure', 101 default=_get_default_uom_id, required=True, 102 help="Default unit of measure used for all stock operations.") 103 uom_name = fields.Char(string='Unit of Measure Name', related='uom_id.name', readonly=True) 104 uom_po_id = fields.Many2one( 105 'uom.uom', 'Purchase Unit of Measure', 106 default=_get_default_uom_id, required=True, 107 help="Default unit of measure used for purchase orders. It must be in the same category as the default unit of measure.") 108 company_id = fields.Many2one( 109 'res.company', 'Company', index=1) 110 packaging_ids = fields.One2many( 111 'product.packaging', string="Product Packages", compute="_compute_packaging_ids", inverse="_set_packaging_ids", 112 help="Gives the different ways to package the same product.") 113 seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id', 'Vendors', depends_context=('company',), help="Define vendor pricelists.") 114 variant_seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id') 115 116 active = fields.Boolean('Active', default=True, help="If unchecked, it will allow you to hide the product without removing it.") 117 color = fields.Integer('Color Index') 118 119 is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant') 120 attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes', copy=True) 121 122 valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line', 123 compute="_compute_valid_product_template_attribute_line_ids", string='Valid Product Attribute Lines', help="Technical compute") 124 125 product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True) 126 # performance: product_variant_id provides prefetching on the first product variant only 127 product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id') 128 129 product_variant_count = fields.Integer( 130 '# Product Variants', compute='_compute_product_variant_count') 131 132 # related to display product product information if is_product_variant 133 barcode = fields.Char('Barcode', compute='_compute_barcode', inverse='_set_barcode', search='_search_barcode') 134 default_code = fields.Char( 135 'Internal Reference', compute='_compute_default_code', 136 inverse='_set_default_code', store=True) 137 138 pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_item_count") 139 140 can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True) 141 has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True) 142 143 def _compute_item_count(self): 144 for template in self: 145 # Pricelist item count counts the rules applicable on current template or on its variants. 146 template.pricelist_item_count = template.env['product.pricelist.item'].search_count([ 147 '|', ('product_tmpl_id', '=', template.id), ('product_id', 'in', template.product_variant_ids.ids)]) 148 149 @api.depends('image_1920', 'image_1024') 150 def _compute_can_image_1024_be_zoomed(self): 151 for template in self: 152 template.can_image_1024_be_zoomed = template.image_1920 and tools.is_image_size_above(template.image_1920, template.image_1024) 153 154 @api.depends('attribute_line_ids', 'attribute_line_ids.value_ids', 'attribute_line_ids.attribute_id.create_variant') 155 def _compute_has_configurable_attributes(self): 156 """A product is considered configurable if: 157 - It has dynamic attributes 158 - It has any attribute line with at least 2 attribute values configured 159 """ 160 for product in self: 161 product.has_configurable_attributes = product.has_dynamic_attributes() or any(len(ptal.value_ids) >= 2 for ptal in product.attribute_line_ids) 162 163 @api.depends('product_variant_ids') 164 def _compute_product_variant_id(self): 165 for p in self: 166 p.product_variant_id = p.product_variant_ids[:1].id 167 168 @api.depends('company_id') 169 def _compute_currency_id(self): 170 main_company = self.env['res.company']._get_main_company() 171 for template in self: 172 template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id 173 174 @api.depends_context('company') 175 def _compute_cost_currency_id(self): 176 self.cost_currency_id = self.env.company.currency_id.id 177 178 def _compute_template_price(self): 179 prices = self._compute_template_price_no_inverse() 180 for template in self: 181 template.price = prices.get(template.id, 0.0) 182 183 def _compute_template_price_no_inverse(self): 184 """The _compute_template_price writes the 'list_price' field with an inverse method 185 This method allows computing the price without writing the 'list_price' 186 """ 187 prices = {} 188 pricelist_id_or_name = self._context.get('pricelist') 189 if pricelist_id_or_name: 190 pricelist = None 191 partner = self.env.context.get('partner') 192 quantity = self.env.context.get('quantity', 1.0) 193 194 # Support context pricelists specified as list, display_name or ID for compatibility 195 if isinstance(pricelist_id_or_name, list): 196 pricelist_id_or_name = pricelist_id_or_name[0] 197 if isinstance(pricelist_id_or_name, str): 198 pricelist_data = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) 199 if pricelist_data: 200 pricelist = self.env['product.pricelist'].browse(pricelist_data[0][0]) 201 elif isinstance(pricelist_id_or_name, int): 202 pricelist = self.env['product.pricelist'].browse(pricelist_id_or_name) 203 204 if pricelist: 205 quantities = [quantity] * len(self) 206 partners = [partner] * len(self) 207 prices = pricelist.get_products_price(self, quantities, partners) 208 209 return prices 210 211 def _set_template_price(self): 212 if self._context.get('uom'): 213 for template in self: 214 value = self.env['uom.uom'].browse(self._context['uom'])._compute_price(template.price, template.uom_id) 215 template.write({'list_price': value}) 216 else: 217 self.write({'list_price': self.price}) 218 219 @api.depends_context('company') 220 @api.depends('product_variant_ids', 'product_variant_ids.standard_price') 221 def _compute_standard_price(self): 222 # Depends on force_company context because standard_price is company_dependent 223 # on the product_product 224 unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) 225 for template in unique_variants: 226 template.standard_price = template.product_variant_ids.standard_price 227 for template in (self - unique_variants): 228 template.standard_price = 0.0 229 230 def _set_standard_price(self): 231 for template in self: 232 if len(template.product_variant_ids) == 1: 233 template.product_variant_ids.standard_price = template.standard_price 234 235 def _search_standard_price(self, operator, value): 236 products = self.env['product.product'].search([('standard_price', operator, value)], limit=None) 237 return [('id', 'in', products.mapped('product_tmpl_id').ids)] 238 239 @api.depends('product_variant_ids', 'product_variant_ids.volume') 240 def _compute_volume(self): 241 unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) 242 for template in unique_variants: 243 template.volume = template.product_variant_ids.volume 244 for template in (self - unique_variants): 245 template.volume = 0.0 246 247 def _set_volume(self): 248 for template in self: 249 if len(template.product_variant_ids) == 1: 250 template.product_variant_ids.volume = template.volume 251 252 @api.depends('product_variant_ids', 'product_variant_ids.weight') 253 def _compute_weight(self): 254 unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) 255 for template in unique_variants: 256 template.weight = template.product_variant_ids.weight 257 for template in (self - unique_variants): 258 template.weight = 0.0 259 260 def _compute_is_product_variant(self): 261 self.is_product_variant = False 262 263 @api.depends('product_variant_ids.barcode') 264 def _compute_barcode(self): 265 self.barcode = False 266 for template in self: 267 if len(template.product_variant_ids) == 1: 268 template.barcode = template.product_variant_ids.barcode 269 270 def _search_barcode(self, operator, value): 271 templates = self.with_context(active_test=False).search([('product_variant_ids.barcode', operator, value)]) 272 return [('id', 'in', templates.ids)] 273 274 def _set_barcode(self): 275 if len(self.product_variant_ids) == 1: 276 self.product_variant_ids.barcode = self.barcode 277 278 @api.model 279 def _get_weight_uom_id_from_ir_config_parameter(self): 280 """ Get the unit of measure to interpret the `weight` field. By default, we considerer 281 that weights are expressed in kilograms. Users can configure to express them in pounds 282 by adding an ir.config_parameter record with "product.product_weight_in_lbs" as key 283 and "1" as value. 284 """ 285 product_weight_in_lbs_param = self.env['ir.config_parameter'].sudo().get_param('product.weight_in_lbs') 286 if product_weight_in_lbs_param == '1': 287 return self.env.ref('uom.product_uom_lb') 288 else: 289 return self.env.ref('uom.product_uom_kgm') 290 291 @api.model 292 def _get_length_uom_id_from_ir_config_parameter(self): 293 """ Get the unit of measure to interpret the `length`, 'width', 'height' field. 294 By default, we considerer that length are expressed in meters. Users can configure 295 to express them in feet by adding an ir.config_parameter record with "product.volume_in_cubic_feet" 296 as key and "1" as value. 297 """ 298 product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet') 299 if product_length_in_feet_param == '1': 300 return self.env.ref('uom.product_uom_foot') 301 else: 302 return self.env.ref('uom.product_uom_meter') 303 304 @api.model 305 def _get_volume_uom_id_from_ir_config_parameter(self): 306 """ Get the unit of measure to interpret the `volume` field. By default, we consider 307 that volumes are expressed in cubic meters. Users can configure to express them in cubic feet 308 by adding an ir.config_parameter record with "product.volume_in_cubic_feet" as key 309 and "1" as value. 310 """ 311 product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet') 312 if product_length_in_feet_param == '1': 313 return self.env.ref('uom.product_uom_cubic_foot') 314 else: 315 return self.env.ref('uom.product_uom_cubic_meter') 316 317 @api.model 318 def _get_weight_uom_name_from_ir_config_parameter(self): 319 return self._get_weight_uom_id_from_ir_config_parameter().display_name 320 321 @api.model 322 def _get_length_uom_name_from_ir_config_parameter(self): 323 return self._get_length_uom_id_from_ir_config_parameter().display_name 324 325 @api.model 326 def _get_volume_uom_name_from_ir_config_parameter(self): 327 return self._get_volume_uom_id_from_ir_config_parameter().display_name 328 329 def _compute_weight_uom_name(self): 330 self.weight_uom_name = self._get_weight_uom_name_from_ir_config_parameter() 331 332 def _compute_volume_uom_name(self): 333 self.volume_uom_name = self._get_volume_uom_name_from_ir_config_parameter() 334 335 def _set_weight(self): 336 for template in self: 337 if len(template.product_variant_ids) == 1: 338 template.product_variant_ids.weight = template.weight 339 340 @api.depends('product_variant_ids.product_tmpl_id') 341 def _compute_product_variant_count(self): 342 for template in self: 343 # do not pollute variants to be prefetched when counting variants 344 template.product_variant_count = len(template.with_prefetch().product_variant_ids) 345 346 @api.depends('product_variant_ids', 'product_variant_ids.default_code') 347 def _compute_default_code(self): 348 unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) 349 for template in unique_variants: 350 template.default_code = template.product_variant_ids.default_code 351 for template in (self - unique_variants): 352 template.default_code = False 353 354 def _set_default_code(self): 355 for template in self: 356 if len(template.product_variant_ids) == 1: 357 template.product_variant_ids.default_code = template.default_code 358 359 @api.depends('product_variant_ids', 'product_variant_ids.packaging_ids') 360 def _compute_packaging_ids(self): 361 for p in self: 362 if len(p.product_variant_ids) == 1: 363 p.packaging_ids = p.product_variant_ids.packaging_ids 364 else: 365 p.packaging_ids = False 366 367 def _set_packaging_ids(self): 368 for p in self: 369 if len(p.product_variant_ids) == 1: 370 p.product_variant_ids.packaging_ids = p.packaging_ids 371 372 @api.constrains('uom_id', 'uom_po_id') 373 def _check_uom(self): 374 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): 375 raise ValidationError(_('The default Unit of Measure and the purchase Unit of Measure must be in the same category.')) 376 return True 377 378 @api.onchange('uom_id') 379 def _onchange_uom_id(self): 380 if self.uom_id: 381 self.uom_po_id = self.uom_id.id 382 383 @api.onchange('uom_po_id') 384 def _onchange_uom(self): 385 if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id: 386 self.uom_po_id = self.uom_id 387 388 @api.onchange('type') 389 def _onchange_type(self): 390 # Do nothing but needed for inheritance 391 return {} 392 393 @api.model_create_multi 394 def create(self, vals_list): 395 ''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date''' 396 templates = super(ProductTemplate, self).create(vals_list) 397 if "create_product_product" not in self._context: 398 templates._create_variant_ids() 399 400 # This is needed to set given values to first variant after creation 401 for template, vals in zip(templates, vals_list): 402 related_vals = {} 403 if vals.get('barcode'): 404 related_vals['barcode'] = vals['barcode'] 405 if vals.get('default_code'): 406 related_vals['default_code'] = vals['default_code'] 407 if vals.get('standard_price'): 408 related_vals['standard_price'] = vals['standard_price'] 409 if vals.get('volume'): 410 related_vals['volume'] = vals['volume'] 411 if vals.get('weight'): 412 related_vals['weight'] = vals['weight'] 413 # Please do forward port 414 if vals.get('packaging_ids'): 415 related_vals['packaging_ids'] = vals['packaging_ids'] 416 if related_vals: 417 template.write(related_vals) 418 419 return templates 420 421 def write(self, vals): 422 if 'uom_id' in vals or 'uom_po_id' in vals: 423 uom_id = self.env['uom.uom'].browse(vals.get('uom_id')) or self.uom_id 424 uom_po_id = self.env['uom.uom'].browse(vals.get('uom_po_id')) or self.uom_po_id 425 if uom_id and uom_po_id and uom_id.category_id != uom_po_id.category_id: 426 vals['uom_po_id'] = uom_id.id 427 res = super(ProductTemplate, self).write(vals) 428 if 'attribute_line_ids' in vals or (vals.get('active') and len(self.product_variant_ids) == 0): 429 self._create_variant_ids() 430 if 'active' in vals and not vals.get('active'): 431 self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')}) 432 if 'image_1920' in vals: 433 self.env['product.product'].invalidate_cache(fnames=[ 434 'image_1920', 435 'image_1024', 436 'image_512', 437 'image_256', 438 'image_128', 439 'can_image_1024_be_zoomed', 440 ]) 441 return res 442 443 @api.returns('self', lambda value: value.id) 444 def copy(self, default=None): 445 # TDE FIXME: should probably be copy_data 446 self.ensure_one() 447 if default is None: 448 default = {} 449 if 'name' not in default: 450 default['name'] = _("%s (copy)", self.name) 451 return super(ProductTemplate, self).copy(default=default) 452 453 def name_get(self): 454 # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields 455 self.browse(self.ids).read(['name', 'default_code']) 456 return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name)) 457 for template in self] 458 459 @api.model 460 def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): 461 # Only use the product.product heuristics if there is a search term and the domain 462 # does not specify a match on `product.template` IDs. 463 if not name or any(term[0] == 'id' for term in (args or [])): 464 return super(ProductTemplate, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) 465 466 Product = self.env['product.product'] 467 templates = self.browse([]) 468 domain_no_variant = [('product_variant_ids', '=', False)] 469 while True: 470 domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or [] 471 args = args if args is not None else [] 472 products_ids = Product._name_search(name, args+domain, operator=operator, name_get_uid=name_get_uid) 473 products = Product.browse(products_ids) 474 new_templates = products.mapped('product_tmpl_id') 475 if new_templates & templates: 476 """Product._name_search can bypass the domain we passed (search on supplier info). 477 If this happens, an infinite loop will occur.""" 478 break 479 templates |= new_templates 480 current_round_templates = self.browse([]) 481 if not products: 482 domain_template = args + domain_no_variant + (templates and [('id', 'not in', templates.ids)] or []) 483 template_ids = super(ProductTemplate, self)._name_search(name=name, args=domain_template, operator=operator, limit=limit, name_get_uid=name_get_uid) 484 current_round_templates |= self.browse(template_ids) 485 templates |= current_round_templates 486 if (not products and not current_round_templates) or (limit and (len(templates) > limit)): 487 break 488 489 searched_ids = set(templates.ids) 490 # some product.templates do not have product.products yet (dynamic variants configuration), 491 # we need to add the base _name_search to the results 492 # FIXME awa: this is really not performant at all but after discussing with the team 493 # we don't see another way to do it 494 if not limit or len(searched_ids) < limit: 495 searched_ids |= set(super(ProductTemplate, self)._name_search( 496 name, 497 args=args, 498 operator=operator, 499 limit=limit, 500 name_get_uid=name_get_uid)) 501 502 # re-apply product.template order + name_get 503 return super(ProductTemplate, self)._name_search( 504 '', args=[('id', 'in', list(searched_ids))], 505 operator='ilike', limit=limit, name_get_uid=name_get_uid) 506 507 def open_pricelist_rules(self): 508 self.ensure_one() 509 domain = ['|', 510 ('product_tmpl_id', '=', self.id), 511 ('product_id', 'in', self.product_variant_ids.ids)] 512 return { 513 'name': _('Price Rules'), 514 'view_mode': 'tree,form', 515 'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')], 516 'res_model': 'product.pricelist.item', 517 'type': 'ir.actions.act_window', 518 'target': 'current', 519 'domain': domain, 520 'context': { 521 'default_product_tmpl_id': self.id, 522 'default_applied_on': '1_product', 523 'product_without_variants': self.product_variant_count == 1, 524 }, 525 } 526 527 def price_compute(self, price_type, uom=False, currency=False, company=None): 528 # TDE FIXME: delegate to template or not ? fields are reencoded here ... 529 # compatibility about context keys used a bit everywhere in the code 530 if not uom and self._context.get('uom'): 531 uom = self.env['uom.uom'].browse(self._context['uom']) 532 if not currency and self._context.get('currency'): 533 currency = self.env['res.currency'].browse(self._context['currency']) 534 535 templates = self 536 if price_type == 'standard_price': 537 # standard_price field can only be seen by users in base.group_user 538 # Thus, in order to compute the sale price from the cost for users not in this group 539 # We fetch the standard price as the superuser 540 templates = self.with_company(company).sudo() 541 if not company: 542 company = self.env.company 543 date = self.env.context.get('date') or fields.Date.today() 544 545 prices = dict.fromkeys(self.ids, 0.0) 546 for template in templates: 547 prices[template.id] = template[price_type] or 0.0 548 # yes, there can be attribute values for product template if it's not a variant YET 549 # (see field product.attribute create_variant) 550 if price_type == 'list_price' and self._context.get('current_attributes_price_extra'): 551 # we have a list of price_extra that comes from the attribute values, we need to sum all that 552 prices[template.id] += sum(self._context.get('current_attributes_price_extra')) 553 554 if uom: 555 prices[template.id] = template.uom_id._compute_price(prices[template.id], uom) 556 557 # Convert from current user company currency to asked one 558 # This is right cause a field cannot be in more than one currency 559 if currency: 560 prices[template.id] = template.currency_id._convert(prices[template.id], currency, company, date) 561 562 return prices 563 564 def _create_variant_ids(self): 565 self.flush() 566 Product = self.env["product.product"] 567 568 variants_to_create = [] 569 variants_to_activate = Product 570 variants_to_unlink = Product 571 572 for tmpl_id in self: 573 lines_without_no_variants = tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes() 574 575 all_variants = tmpl_id.with_context(active_test=False).product_variant_ids.sorted(lambda p: (p.active, -p.id)) 576 577 current_variants_to_create = [] 578 current_variants_to_activate = Product 579 580 # adding an attribute with only one value should not recreate product 581 # write this attribute on every product to make sure we don't lose them 582 single_value_lines = lines_without_no_variants.filtered(lambda ptal: len(ptal.product_template_value_ids._only_active()) == 1) 583 if single_value_lines: 584 for variant in all_variants: 585 combination = variant.product_template_attribute_value_ids | single_value_lines.product_template_value_ids._only_active() 586 # Do not add single value if the resulting combination would 587 # be invalid anyway. 588 if ( 589 len(combination) == len(lines_without_no_variants) and 590 combination.attribute_line_id == lines_without_no_variants 591 ): 592 variant.product_template_attribute_value_ids = combination 593 594 # Set containing existing `product.template.attribute.value` combination 595 existing_variants = { 596 variant.product_template_attribute_value_ids: variant for variant in all_variants 597 } 598 599 # Determine which product variants need to be created based on the attribute 600 # configuration. If any attribute is set to generate variants dynamically, skip the 601 # process. 602 # Technical note: if there is no attribute, a variant is still created because 603 # 'not any([])' and 'set([]) not in set([])' are True. 604 if not tmpl_id.has_dynamic_attributes(): 605 # Iterator containing all possible `product.template.attribute.value` combination 606 # The iterator is used to avoid MemoryError in case of a huge number of combination. 607 all_combinations = itertools.product(*[ 608 ptal.product_template_value_ids._only_active() for ptal in lines_without_no_variants 609 ]) 610 # For each possible variant, create if it doesn't exist yet. 611 for combination_tuple in all_combinations: 612 combination = self.env['product.template.attribute.value'].concat(*combination_tuple) 613 if combination in existing_variants: 614 current_variants_to_activate += existing_variants[combination] 615 else: 616 current_variants_to_create.append({ 617 'product_tmpl_id': tmpl_id.id, 618 'product_template_attribute_value_ids': [(6, 0, combination.ids)], 619 'active': tmpl_id.active, 620 }) 621 if len(current_variants_to_create) > 1000: 622 raise UserError(_( 623 'The number of variants to generate is too high. ' 624 'You should either not generate variants for each combination or generate them on demand from the sales order. ' 625 'To do so, open the form view of attributes and change the mode of *Create Variants*.')) 626 variants_to_create += current_variants_to_create 627 variants_to_activate += current_variants_to_activate 628 629 else: 630 for variant in existing_variants.values(): 631 is_combination_possible = self._is_combination_possible_by_config( 632 combination=variant.product_template_attribute_value_ids, 633 ignore_no_variant=True, 634 ) 635 if is_combination_possible: 636 current_variants_to_activate += variant 637 variants_to_activate += current_variants_to_activate 638 639 variants_to_unlink += all_variants - current_variants_to_activate 640 641 if variants_to_activate: 642 variants_to_activate.write({'active': True}) 643 if variants_to_create: 644 Product.create(variants_to_create) 645 if variants_to_unlink: 646 variants_to_unlink._unlink_or_archive() 647 648 # prefetched o2m have to be reloaded (because of active_test) 649 # (eg. product.template: product_variant_ids) 650 # We can't rely on existing invalidate_cache because of the savepoint 651 # in _unlink_or_archive. 652 self.flush() 653 self.invalidate_cache() 654 return True 655 656 def has_dynamic_attributes(self): 657 """Return whether this `product.template` has at least one dynamic 658 attribute. 659 660 :return: True if at least one dynamic attribute, False otherwise 661 :rtype: bool 662 """ 663 self.ensure_one() 664 return any(a.create_variant == 'dynamic' for a in self.valid_product_template_attribute_line_ids.attribute_id) 665 666 @api.depends('attribute_line_ids.value_ids') 667 def _compute_valid_product_template_attribute_line_ids(self): 668 """A product template attribute line is considered valid if it has at 669 least one possible value. 670 671 Those with only one value are considered valid, even though they should 672 not appear on the configurator itself (unless they have an is_custom 673 value to input), indeed single value attributes can be used to filter 674 products among others based on that attribute/value. 675 """ 676 for record in self: 677 record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids) 678 679 def _get_possible_variants(self, parent_combination=None): 680 """Return the existing variants that are possible. 681 682 For dynamic attributes, it will only return the variants that have been 683 created already. 684 685 If there are a lot of variants, this method might be slow. Even if there 686 aren't too many variants, for performance reasons, do not call this 687 method in a loop over the product templates. 688 689 Therefore this method has a very restricted reasonable use case and you 690 should strongly consider doing things differently if you consider using 691 this method. 692 693 :param parent_combination: combination from which `self` is an 694 optional or accessory product. 695 :type parent_combination: recordset `product.template.attribute.value` 696 697 :return: the existing variants that are possible. 698 :rtype: recordset of `product.product` 699 """ 700 self.ensure_one() 701 return self.product_variant_ids.filtered(lambda p: p._is_variant_possible(parent_combination)) 702 703 def _get_attribute_exclusions(self, parent_combination=None, parent_name=None): 704 """Return the list of attribute exclusions of a product. 705 706 :param parent_combination: the combination from which 707 `self` is an optional or accessory product. Indeed exclusions 708 rules on one product can concern another product. 709 :type parent_combination: recordset `product.template.attribute.value` 710 :param parent_name: the name of the parent product combination. 711 :type parent_name: str 712 713 :return: dict of exclusions 714 - exclusions: from this product itself 715 - parent_combination: ids of the given parent_combination 716 - parent_exclusions: from the parent_combination 717 - parent_product_name: the name of the parent product if any, used in the interface 718 to explain why some combinations are not available. 719 (e.g: Not available with Customizable Desk (Legs: Steel)) 720 - mapped_attribute_names: the name of every attribute values based on their id, 721 used to explain in the interface why that combination is not available 722 (e.g: Not available with Color: Black) 723 """ 724 self.ensure_one() 725 parent_combination = parent_combination or self.env['product.template.attribute.value'] 726 return { 727 'exclusions': self._complete_inverse_exclusions(self._get_own_attribute_exclusions()), 728 'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination), 729 'parent_combination': parent_combination.ids, 730 'parent_product_name': parent_name, 731 'mapped_attribute_names': self._get_mapped_attribute_names(parent_combination), 732 } 733 734 @api.model 735 def _complete_inverse_exclusions(self, exclusions): 736 """Will complete the dictionnary of exclusions with their respective inverse 737 e.g: Black excludes XL and L 738 -> XL excludes Black 739 -> L excludes Black""" 740 result = dict(exclusions) 741 for key, value in exclusions.items(): 742 for exclusion in value: 743 if exclusion in result and key not in result[exclusion]: 744 result[exclusion].append(key) 745 else: 746 result[exclusion] = [key] 747 748 return result 749 750 def _get_own_attribute_exclusions(self): 751 """Get exclusions coming from the current template. 752 753 Dictionnary, each product template attribute value is a key, and for each of them 754 the value is an array with the other ptav that they exclude (empty if no exclusion). 755 """ 756 self.ensure_one() 757 product_template_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids 758 return { 759 ptav.id: [ 760 value_id 761 for filter_line in ptav.exclude_for.filtered( 762 lambda filter_line: filter_line.product_tmpl_id == self 763 ) for value_id in filter_line.value_ids.ids 764 ] 765 for ptav in product_template_attribute_values 766 } 767 768 def _get_parent_attribute_exclusions(self, parent_combination): 769 """Get exclusions coming from the parent combination. 770 771 Dictionnary, each parent's ptav is a key, and for each of them the value is 772 an array with the other ptav that are excluded because of the parent. 773 """ 774 self.ensure_one() 775 if not parent_combination: 776 return {} 777 778 result = {} 779 for product_attribute_value in parent_combination: 780 for filter_line in product_attribute_value.exclude_for.filtered( 781 lambda filter_line: filter_line.product_tmpl_id == self 782 ): 783 # Some exclusions don't have attribute value. This means that the template is not 784 # compatible with the parent combination. If such an exclusion is found, it means that all 785 # attribute values are excluded. 786 if filter_line.value_ids: 787 result[product_attribute_value.id] = filter_line.value_ids.ids 788 else: 789 result[product_attribute_value.id] = filter_line.product_tmpl_id.mapped('attribute_line_ids.product_template_value_ids').ids 790 791 return result 792 793 def _get_mapped_attribute_names(self, parent_combination=None): 794 """ The name of every attribute values based on their id, 795 used to explain in the interface why that combination is not available 796 (e.g: Not available with Color: Black). 797 798 It contains both attribute value names from this product and from 799 the parent combination if provided. 800 """ 801 self.ensure_one() 802 all_product_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids 803 if parent_combination: 804 all_product_attribute_values |= parent_combination 805 806 return { 807 attribute_value.id: attribute_value.display_name 808 for attribute_value in all_product_attribute_values 809 } 810 811 def _is_combination_possible_by_config(self, combination, ignore_no_variant=False): 812 """Return whether the given combination is possible according to the config of attributes on the template 813 814 :param combination: the combination to check for possibility 815 :type combination: recordset `product.template.attribute.value` 816 817 :param ignore_no_variant: whether no_variant attributes should be ignored 818 :type ignore_no_variant: bool 819 820 :return: wether the given combination is possible according to the config of attributes on the template 821 :rtype: bool 822 """ 823 self.ensure_one() 824 825 attribute_lines = self.valid_product_template_attribute_line_ids 826 827 if ignore_no_variant: 828 attribute_lines = attribute_lines._without_no_variant_attributes() 829 830 if len(combination) != len(attribute_lines): 831 # number of attribute values passed is different than the 832 # configuration of attributes on the template 833 return False 834 835 if attribute_lines != combination.attribute_line_id: 836 # combination has different attributes than the ones configured on the template 837 return False 838 839 if not (attribute_lines.product_template_value_ids._only_active() >= combination): 840 # combination has different values than the ones configured on the template 841 return False 842 843 return True 844 845 def _is_combination_possible(self, combination, parent_combination=None, ignore_no_variant=False): 846 """ 847 The combination is possible if it is not excluded by any rule 848 coming from the current template, not excluded by any rule from the 849 parent_combination (if given), and there should not be any archived 850 variant with the exact same combination. 851 852 If the template does not have any dynamic attribute, the combination 853 is also not possible if the matching variant has been deleted. 854 855 Moreover the attributes of the combination must excatly match the 856 attributes allowed on the template. 857 858 :param combination: the combination to check for possibility 859 :type combination: recordset `product.template.attribute.value` 860 861 :param ignore_no_variant: whether no_variant attributes should be ignored 862 :type ignore_no_variant: bool 863 864 :param parent_combination: combination from which `self` is an 865 optional or accessory product. 866 :type parent_combination: recordset `product.template.attribute.value` 867 868 :return: whether the combination is possible 869 :rtype: bool 870 """ 871 self.ensure_one() 872 873 if not self._is_combination_possible_by_config(combination, ignore_no_variant): 874 return False 875 876 variant = self._get_variant_for_combination(combination) 877 878 if self.has_dynamic_attributes(): 879 if variant and not variant.active: 880 # dynamic and the variant has been archived 881 return False 882 else: 883 if not variant or not variant.active: 884 # not dynamic, the variant has been archived or deleted 885 return False 886 887 exclusions = self._get_own_attribute_exclusions() 888 if exclusions: 889 # exclude if the current value is in an exclusion, 890 # and the value excluding it is also in the combination 891 for ptav in combination: 892 for exclusion in exclusions.get(ptav.id): 893 if exclusion in combination.ids: 894 return False 895 896 parent_exclusions = self._get_parent_attribute_exclusions(parent_combination) 897 if parent_exclusions: 898 # parent_exclusion are mapped by ptav but here we don't need to know 899 # where the exclusion comes from so we loop directly on the dict values 900 for exclusions_values in parent_exclusions.values(): 901 for exclusion in exclusions_values: 902 if exclusion in combination.ids: 903 return False 904 905 return True 906 907 def _get_variant_for_combination(self, combination): 908 """Get the variant matching the combination. 909 910 All of the values in combination must be present in the variant, and the 911 variant should not have more attributes. Ignore the attributes that are 912 not supposed to create variants. 913 914 :param combination: recordset of `product.template.attribute.value` 915 916 :return: the variant if found, else empty 917 :rtype: recordset `product.product` 918 """ 919 self.ensure_one() 920 filtered_combination = combination._without_no_variant_attributes() 921 return self.env['product.product'].browse(self._get_variant_id_for_combination(filtered_combination)) 922 923 def _create_product_variant(self, combination, log_warning=False): 924 """ Create if necessary and possible and return the product variant 925 matching the given combination for this template. 926 927 It is possible to create only if the template has dynamic attributes 928 and the combination itself is possible. 929 If we are in this case and the variant already exists but it is 930 archived, it is activated instead of being created again. 931 932 :param combination: the combination for which to get or create variant. 933 The combination must contain all necessary attributes, including 934 those of type no_variant. Indeed even though those attributes won't 935 be included in the variant if newly created, they are needed when 936 checking if the combination is possible. 937 :type combination: recordset of `product.template.attribute.value` 938 939 :param log_warning: whether a warning should be logged on fail 940 :type log_warning: bool 941 942 :return: the product variant matching the combination or none 943 :rtype: recordset of `product.product` 944 """ 945 self.ensure_one() 946 947 Product = self.env['product.product'] 948 949 product_variant = self._get_variant_for_combination(combination) 950 if product_variant: 951 if not product_variant.active and self.has_dynamic_attributes() and self._is_combination_possible(combination): 952 product_variant.active = True 953 return product_variant 954 955 if not self.has_dynamic_attributes(): 956 if log_warning: 957 _logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id)) 958 return Product 959 960 if not self._is_combination_possible(combination): 961 if log_warning: 962 _logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id)) 963 return Product 964 965 return Product.sudo().create({ 966 'product_tmpl_id': self.id, 967 'product_template_attribute_value_ids': [(6, 0, combination._without_no_variant_attributes().ids)] 968 }) 969 970 @tools.ormcache('self.id', 'frozenset(filtered_combination.ids)') 971 def _get_variant_id_for_combination(self, filtered_combination): 972 """See `_get_variant_for_combination`. This method returns an ID 973 so it can be cached. 974 975 Use sudo because the same result should be cached for all users. 976 """ 977 self.ensure_one() 978 domain = [('product_tmpl_id', '=', self.id)] 979 combination_indices_ids = filtered_combination._ids2str() 980 981 if combination_indices_ids: 982 domain = expression.AND([domain, [('combination_indices', '=', combination_indices_ids)]]) 983 else: 984 domain = expression.AND([domain, [('combination_indices', 'in', ['', False])]]) 985 986 return self.env['product.product'].sudo().with_context(active_test=False).search(domain, order='active DESC', limit=1).id 987 988 @tools.ormcache('self.id') 989 def _get_first_possible_variant_id(self): 990 """See `_create_first_product_variant`. This method returns an ID 991 so it can be cached.""" 992 self.ensure_one() 993 return self._create_first_product_variant().id 994 995 def _get_first_possible_combination(self, parent_combination=None, necessary_values=None): 996 """See `_get_possible_combinations` (one iteration). 997 998 This method return the same result (empty recordset) if no 999 combination is possible at all which would be considered a negative 1000 result, or if there are no attribute lines on the template in which 1001 case the "empty combination" is actually a possible combination. 1002 Therefore the result of this method when empty should be tested 1003 with `_is_combination_possible` if it's important to know if the 1004 resulting empty combination is actually possible or not. 1005 """ 1006 return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value']) 1007 1008 def _cartesian_product(self, product_template_attribute_values_per_line, parent_combination): 1009 """ 1010 Generate all possible combination for attributes values (aka cartesian product). 1011 It is equivalent to itertools.product except it skips invalid partial combinations before they are complete. 1012 1013 Imagine the cartesian product of 'A', 'CD' and range(1_000_000) and let's say that 'A' and 'C' are incompatible. 1014 If you use itertools.product or any normal cartesian product, you'll need to filter out of the final result 1015 the 1_000_000 combinations that start with 'A' and 'C' . Instead, This implementation will test if 'A' and 'C' are 1016 compatible before even considering range(1_000_000), skip it and and continue with combinations that start 1017 with 'A' and 'D'. 1018 1019 It's necessary for performance reason because filtering out invalid combinations from standard Cartesian product 1020 can be extremely slow 1021 1022 :param product_template_attribute_values_per_line: the values we want all the possibles combinations of. 1023 One list of values by attribute line 1024 :return: a generator of product template attribute value 1025 """ 1026 if not product_template_attribute_values_per_line: 1027 return 1028 1029 all_exclusions = {self.env['product.template.attribute.value'].browse(k): 1030 self.env['product.template.attribute.value'].browse(v) for k, v in 1031 self._get_own_attribute_exclusions().items()} 1032 # The following dict uses product template attribute values as keys 1033 # 0 means the value is acceptable, greater than 0 means it's rejected, it cannot be negative 1034 # Bear in mind that several values can reject the same value and the latter can only be included in the 1035 # considered combination if no value rejects it. 1036 # This dictionary counts how many times each value is rejected. 1037 # Each time a value is included in the considered combination, the values it rejects are incremented 1038 # When a value is discarded from the considered combination, the values it rejects are decremented 1039 current_exclusions = defaultdict(int) 1040 for exclusion in self._get_parent_attribute_exclusions(parent_combination): 1041 current_exclusions[self.env['product.template.attribute.value'].browse(exclusion)] += 1 1042 partial_combination = self.env['product.template.attribute.value'] 1043 1044 # The following list reflects product_template_attribute_values_per_line 1045 # For each line, instead of a list of values, it contains the index of the selected value 1046 # -1 means no value has been picked for the line in the current (partial) combination 1047 value_index_per_line = [-1] * len(product_template_attribute_values_per_line) 1048 # determines which line line we're working on 1049 line_index = 0 1050 1051 while True: 1052 current_line_values = product_template_attribute_values_per_line[line_index] 1053 current_ptav_index = value_index_per_line[line_index] 1054 current_ptav = current_line_values[current_ptav_index] 1055 1056 # removing exclusions from current_ptav as we're removing it from partial_combination 1057 if current_ptav_index >= 0: 1058 for ptav_to_include_back in all_exclusions[current_ptav]: 1059 current_exclusions[ptav_to_include_back] -= 1 1060 partial_combination -= current_ptav 1061 1062 if current_ptav_index < len(current_line_values) - 1: 1063 # go to next value of current line 1064 value_index_per_line[line_index] += 1 1065 current_line_values = product_template_attribute_values_per_line[line_index] 1066 current_ptav_index = value_index_per_line[line_index] 1067 current_ptav = current_line_values[current_ptav_index] 1068 elif line_index != 0: 1069 # reset current line, and then go to previous line 1070 value_index_per_line[line_index] = - 1 1071 line_index -= 1 1072 continue 1073 else: 1074 # we're done if we must reset first line 1075 break 1076 1077 # adding exclusions from current_ptav as we're incorporating it in partial_combination 1078 for ptav_to_exclude in all_exclusions[current_ptav]: 1079 current_exclusions[ptav_to_exclude] += 1 1080 partial_combination += current_ptav 1081 1082 # test if included values excludes current value or if current value exclude included values 1083 if current_exclusions[current_ptav] or \ 1084 any(intersection in partial_combination for intersection in all_exclusions[current_ptav]): 1085 continue 1086 1087 if line_index == len(product_template_attribute_values_per_line) - 1: 1088 # submit combination if we're on the last line 1089 yield partial_combination 1090 else: 1091 # else we go to the next line 1092 line_index += 1 1093 1094 def _get_possible_combinations(self, parent_combination=None, necessary_values=None): 1095 """Generator returning combinations that are possible, following the 1096 sequence of attributes and values. 1097 1098 See `_is_combination_possible` for what is a possible combination. 1099 1100 When encountering an impossible combination, try to change the value 1101 of attributes by starting with the further regarding their sequences. 1102 1103 Ignore attributes that have no values. 1104 1105 :param parent_combination: combination from which `self` is an 1106 optional or accessory product. 1107 :type parent_combination: recordset `product.template.attribute.value` 1108 1109 :param necessary_values: values that must be in the returned combination 1110 :type necessary_values: recordset of `product.template.attribute.value` 1111 1112 :return: the possible combinations 1113 :rtype: generator of recordset of `product.template.attribute.value` 1114 """ 1115 self.ensure_one() 1116 1117 if not self.active: 1118 return _("The product template is archived so no combination is possible.") 1119 1120 necessary_values = necessary_values or self.env['product.template.attribute.value'] 1121 necessary_attribute_lines = necessary_values.mapped('attribute_line_id') 1122 attribute_lines = self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal not in necessary_attribute_lines) 1123 1124 if not attribute_lines and self._is_combination_possible(necessary_values, parent_combination): 1125 yield necessary_values 1126 1127 product_template_attribute_values_per_line = [ 1128 ptal.product_template_value_ids._only_active() 1129 for ptal in attribute_lines 1130 ] 1131 1132 for partial_combination in self._cartesian_product(product_template_attribute_values_per_line, parent_combination): 1133 combination = partial_combination + necessary_values 1134 if self._is_combination_possible(combination, parent_combination): 1135 yield combination 1136 1137 return _("There are no remaining possible combination.") 1138 1139 def _get_closest_possible_combination(self, combination): 1140 """See `_get_closest_possible_combinations` (one iteration). 1141 1142 This method return the same result (empty recordset) if no 1143 combination is possible at all which would be considered a negative 1144 result, or if there are no attribute lines on the template in which 1145 case the "empty combination" is actually a possible combination. 1146 Therefore the result of this method when empty should be tested 1147 with `_is_combination_possible` if it's important to know if the 1148 resulting empty combination is actually possible or not. 1149 """ 1150 return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value']) 1151 1152 def _get_closest_possible_combinations(self, combination): 1153 """Generator returning the possible combinations that are the closest to 1154 the given combination. 1155 1156 If the given combination is incomplete, try to complete it. 1157 1158 If the given combination is invalid, try to remove values from it before 1159 completing it. 1160 1161 :param combination: the values to include if they are possible 1162 :type combination: recordset `product.template.attribute.value` 1163 1164 :return: the possible combinations that are including as much 1165 elements as possible from the given combination. 1166 :rtype: generator of recordset of product.template.attribute.value 1167 """ 1168 while True: 1169 res = self._get_possible_combinations(necessary_values=combination) 1170 try: 1171 # If there is at least one result for the given combination 1172 # we consider that combination set, and we yield all the 1173 # possible combinations for it. 1174 yield(next(res)) 1175 for cur in res: 1176 yield(cur) 1177 return _("There are no remaining closest combination.") 1178 except StopIteration: 1179 # There are no results for the given combination, we try to 1180 # progressively remove values from it. 1181 if not combination: 1182 return _("There are no possible combination.") 1183 combination = combination[:-1] 1184 1185 def _get_current_company(self, **kwargs): 1186 """Get the most appropriate company for this product. 1187 1188 If the company is set on the product, directly return it. Otherwise, 1189 fallback to a contextual company. 1190 1191 :param kwargs: kwargs forwarded to the fallback method. 1192 1193 :return: the most appropriate company for this product 1194 :rtype: recordset of one `res.company` 1195 """ 1196 self.ensure_one() 1197 return self.company_id or self._get_current_company_fallback(**kwargs) 1198 1199 def _get_current_company_fallback(self, **kwargs): 1200 """Fallback to get the most appropriate company for this product. 1201 1202 This should only be called from `_get_current_company` but is defined 1203 separately to allow override. 1204 1205 The final fallback will be the current user's company. 1206 1207 :return: the fallback company for this product 1208 :rtype: recordset of one `res.company` 1209 """ 1210 self.ensure_one() 1211 return self.env.company 1212 1213 def get_single_product_variant(self): 1214 """ Method used by the product configurator to check if the product is configurable or not. 1215 1216 We need to open the product configurator if the product: 1217 - is configurable (see has_configurable_attributes) 1218 - has optional products (method is extended in sale to return optional products info) 1219 """ 1220 self.ensure_one() 1221 if self.product_variant_count == 1 and not self.has_configurable_attributes: 1222 return { 1223 'product_id': self.product_variant_id.id, 1224 } 1225 return {} 1226 1227 @api.model 1228 def get_empty_list_help(self, help): 1229 self = self.with_context( 1230 empty_list_help_document_name=_("product"), 1231 ) 1232 return super(ProductTemplate, self).get_empty_list_help(help) 1233 1234 @api.model 1235 def get_import_templates(self): 1236 return [{ 1237 'label': _('Import Template for Products'), 1238 'template': '/product/static/xls/product_template.xls' 1239 }] 1240