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