1440 lines
67 KiB
Python
1440 lines
67 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import itertools
|
|
import logging
|
|
from collections import defaultdict
|
|
|
|
from odoo import api, fields, models, tools, _, SUPERUSER_ID
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.osv import expression
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
PRICE_CONTEXT_KEYS = ['pricelist', 'quantity', 'uom', 'date']
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_name = "product.template"
|
|
_inherit = ['mail.thread', 'mail.activity.mixin', 'image.mixin']
|
|
_description = "Product"
|
|
_order = "priority desc, name"
|
|
|
|
@tools.ormcache()
|
|
def _get_default_category_id(self):
|
|
# Deletion forbidden (at least through unlink)
|
|
return self.env.ref('product.product_category_all')
|
|
|
|
@tools.ormcache()
|
|
def _get_default_uom_id(self):
|
|
# Deletion forbidden (at least through unlink)
|
|
return self.env.ref('uom.product_uom_unit')
|
|
|
|
def _get_default_uom_po_id(self):
|
|
return self.default_get(['uom_id']).get('uom_id') or self._get_default_uom_id()
|
|
|
|
def _read_group_categ_id(self, categories, domain, order):
|
|
category_ids = self.env.context.get('default_categ_id')
|
|
if not category_ids and self.env.context.get('group_expand'):
|
|
category_ids = categories._search([], order=order, access_rights_uid=SUPERUSER_ID)
|
|
return categories.browse(category_ids)
|
|
|
|
name = fields.Char('Name', index='trigram', required=True, translate=True)
|
|
sequence = fields.Integer('Sequence', default=1, help='Gives the sequence order when displaying a product list')
|
|
description = fields.Html(
|
|
'Description', translate=True)
|
|
description_purchase = fields.Text(
|
|
'Purchase Description', translate=True)
|
|
description_sale = fields.Text(
|
|
'Sales Description', translate=True,
|
|
help="A description of the Product that you want to communicate to your customers. "
|
|
"This description will be copied to every Sales Order, Delivery Order and Customer Invoice/Credit Note")
|
|
detailed_type = fields.Selection([
|
|
('consu', 'Consumable'),
|
|
('service', 'Service')], string='Product Type', default='consu', required=True,
|
|
help='A storable product is a product for which you manage stock. The Inventory app has to be installed.\n'
|
|
'A consumable product is a product for which stock is not managed.\n'
|
|
'A service is a non-material product you provide.')
|
|
type = fields.Selection(
|
|
[('consu', 'Consumable'),
|
|
('service', 'Service')],
|
|
compute='_compute_type', store=True, readonly=False, precompute=True)
|
|
categ_id = fields.Many2one(
|
|
'product.category', 'Product Category',
|
|
change_default=True, default=_get_default_category_id, group_expand='_read_group_categ_id',
|
|
required=True)
|
|
|
|
currency_id = fields.Many2one(
|
|
'res.currency', 'Currency', compute='_compute_currency_id')
|
|
cost_currency_id = fields.Many2one(
|
|
'res.currency', 'Cost Currency', compute='_compute_cost_currency_id')
|
|
|
|
# list_price: catalog price, user defined
|
|
list_price = fields.Float(
|
|
'Sales Price', default=1.0,
|
|
digits='Product Price',
|
|
help="Price at which the product is sold to customers.",
|
|
)
|
|
standard_price = fields.Float(
|
|
'Cost', compute='_compute_standard_price',
|
|
inverse='_set_standard_price', search='_search_standard_price',
|
|
digits='Product Price', groups="base.group_user",
|
|
help="""Value of the product (automatically computed in AVCO).
|
|
Used to value the product when the purchase cost is not known (e.g. inventory adjustment).
|
|
Used to compute margins on sale orders.""")
|
|
|
|
volume = fields.Float(
|
|
'Volume', compute='_compute_volume', inverse='_set_volume', digits='Volume', store=True)
|
|
volume_uom_name = fields.Char(string='Volume unit of measure label', compute='_compute_volume_uom_name')
|
|
weight = fields.Float(
|
|
'Weight', compute='_compute_weight', digits='Stock Weight',
|
|
inverse='_set_weight', store=True)
|
|
weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name')
|
|
|
|
sale_ok = fields.Boolean('Can be Sold', default=True)
|
|
purchase_ok = fields.Boolean('Can be Purchased', default=True)
|
|
uom_id = fields.Many2one(
|
|
'uom.uom', 'Unit of Measure',
|
|
default=_get_default_uom_id, required=True,
|
|
help="Default unit of measure used for all stock operations.")
|
|
uom_name = fields.Char(string='Unit of Measure Name', related='uom_id.name', readonly=True)
|
|
uom_po_id = fields.Many2one(
|
|
'uom.uom', 'Purchase UoM',
|
|
default=_get_default_uom_po_id, required=True,
|
|
help="Default unit of measure used for purchase orders. It must be in the same category as the default unit of measure.")
|
|
company_id = fields.Many2one(
|
|
'res.company', 'Company', index=True)
|
|
packaging_ids = fields.One2many(
|
|
'product.packaging', string="Product Packages", compute="_compute_packaging_ids", inverse="_set_packaging_ids",
|
|
help="Gives the different ways to package the same product.")
|
|
seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id', 'Vendors', depends_context=('company',))
|
|
variant_seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id')
|
|
|
|
active = fields.Boolean('Active', default=True, help="If unchecked, it will allow you to hide the product without removing it.")
|
|
color = fields.Integer('Color Index')
|
|
|
|
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
|
|
attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes', copy=True)
|
|
|
|
valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
|
|
compute="_compute_valid_product_template_attribute_line_ids", string='Valid Product Attribute Lines')
|
|
|
|
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
|
|
# performance: product_variant_id provides prefetching on the first product variant only
|
|
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
|
|
|
|
product_variant_count = fields.Integer(
|
|
'# Product Variants', compute='_compute_product_variant_count')
|
|
|
|
# related to display product product information if is_product_variant
|
|
barcode = fields.Char('Barcode', compute='_compute_barcode', inverse='_set_barcode', search='_search_barcode')
|
|
default_code = fields.Char(
|
|
'Internal Reference', compute='_compute_default_code',
|
|
inverse='_set_default_code', store=True)
|
|
|
|
pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_item_count")
|
|
|
|
product_document_ids = fields.One2many(
|
|
string="Documents",
|
|
comodel_name='product.document',
|
|
inverse_name='res_id',
|
|
domain=lambda self: [('res_model', '=', self._name)])
|
|
product_document_count = fields.Integer(
|
|
string="Documents Count", compute='_compute_product_document_count')
|
|
|
|
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
|
|
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
|
|
|
|
product_tooltip = fields.Char(compute='_compute_product_tooltip')
|
|
|
|
priority = fields.Selection([
|
|
('0', 'Normal'),
|
|
('1', 'Favorite'),
|
|
], default='0', string="Favorite")
|
|
|
|
product_tag_ids = fields.Many2many(
|
|
string="Product Template Tags",
|
|
comodel_name='product.tag',
|
|
relation='product_tag_product_template_rel',
|
|
)
|
|
# Properties
|
|
product_properties = fields.Properties('Properties', definition='categ_id.product_properties_definition', copy=True)
|
|
|
|
def _compute_item_count(self):
|
|
for template in self:
|
|
# Pricelist item count counts the rules applicable on current template or on its variants.
|
|
template.pricelist_item_count = template.env['product.pricelist.item'].search_count([
|
|
'&',
|
|
'|', ('product_tmpl_id', '=', template.id), ('product_id', 'in', template.product_variant_ids.ids),
|
|
('pricelist_id.active', '=', True),
|
|
])
|
|
|
|
def _compute_product_document_count(self):
|
|
for template in self:
|
|
template.product_document_count = template.env['product.document'].search_count([
|
|
'|',
|
|
'&', ('res_model', '=', 'product.template'), ('res_id', '=', template.id),
|
|
'&',
|
|
('res_model', '=', 'product.product'),
|
|
('res_id', 'in', template.product_variant_ids.ids),
|
|
])
|
|
|
|
@api.depends('image_1920', 'image_1024')
|
|
def _compute_can_image_1024_be_zoomed(self):
|
|
for template in self:
|
|
template.can_image_1024_be_zoomed = template.image_1920 and tools.is_image_size_above(template.image_1920, template.image_1024)
|
|
|
|
@api.depends('attribute_line_ids', 'attribute_line_ids.value_ids', 'attribute_line_ids.attribute_id.create_variant')
|
|
def _compute_has_configurable_attributes(self):
|
|
"""A product is considered configurable if:
|
|
- It has dynamic attributes
|
|
- It has any attribute line with at least 2 attribute values configured
|
|
"""
|
|
for product in self:
|
|
product.has_configurable_attributes = product.has_dynamic_attributes() or any(len(ptal.value_ids) >= 2 for ptal in product.attribute_line_ids)
|
|
|
|
@api.depends('product_variant_ids')
|
|
def _compute_product_variant_id(self):
|
|
for p in self:
|
|
p.product_variant_id = p.product_variant_ids[:1].id
|
|
|
|
@api.constrains('company_id')
|
|
def _check_barcode_uniqueness(self):
|
|
for template in self:
|
|
template.product_variant_ids._check_barcode_uniqueness()
|
|
|
|
@api.depends('company_id')
|
|
def _compute_currency_id(self):
|
|
main_company = self.env['res.company']._get_main_company()
|
|
for template in self:
|
|
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
|
|
|
|
@api.depends('company_id')
|
|
@api.depends_context('company')
|
|
def _compute_cost_currency_id(self):
|
|
env_currency_id = self.env.company.currency_id.id
|
|
for template in self:
|
|
template.cost_currency_id = template.company_id.currency_id.id or env_currency_id
|
|
|
|
def _compute_template_field_from_variant_field(self, fname, default=False):
|
|
"""Sets the value of the given field based on the template variant values
|
|
|
|
Equals to product_variant_ids[fname] if it's a single variant product.
|
|
Otherwise, sets the value specified in ``default``.
|
|
It's used to compute fields like barcode, weight, volume..
|
|
|
|
:param str fname: name of the field to compute
|
|
(field name must be identical between product.product & product.template models)
|
|
:param default: default value to set when there are multiple or no variants on the template
|
|
:return: None
|
|
"""
|
|
for template in self:
|
|
variant_count = len(template.product_variant_ids)
|
|
if variant_count == 1:
|
|
template[fname] = template.product_variant_ids[fname]
|
|
elif variant_count == 0 and self.env.context.get("active_test", True):
|
|
# If the product has no active variants, retry without the active_test
|
|
template_ctx = template.with_context(active_test=False)
|
|
template_ctx._compute_template_field_from_variant_field(fname, default=default)
|
|
else:
|
|
template[fname] = default
|
|
|
|
def _set_product_variant_field(self, fname):
|
|
"""Propagate the value of the given field from the templates to their unique variant.
|
|
|
|
Only if it's a single variant product.
|
|
It's used to set fields like barcode, weight, volume..
|
|
|
|
:param str fname: name of the field whose value should be propagated to the variant.
|
|
(field name must be identical between product.product & product.template models)
|
|
"""
|
|
for template in self:
|
|
count = len(template.product_variant_ids)
|
|
if count == 1:
|
|
template.product_variant_ids[fname] = template[fname]
|
|
elif count == 0:
|
|
archived_variants = self.with_context(active_test=False).product_variant_ids
|
|
if len(archived_variants) == 1:
|
|
archived_variants[fname] = template[fname]
|
|
|
|
@api.depends_context('company')
|
|
@api.depends('product_variant_ids.standard_price')
|
|
def _compute_standard_price(self):
|
|
# Depends on force_company context because standard_price is company_dependent
|
|
# on the product_product
|
|
self._compute_template_field_from_variant_field('standard_price')
|
|
|
|
def _set_standard_price(self):
|
|
self._set_product_variant_field('standard_price')
|
|
|
|
def _search_standard_price(self, operator, value):
|
|
return [('product_variant_ids.standard_price', operator, value)]
|
|
|
|
@api.depends('product_variant_ids.volume')
|
|
def _compute_volume(self):
|
|
self._compute_template_field_from_variant_field('volume')
|
|
|
|
def _set_volume(self):
|
|
self._set_product_variant_field('volume')
|
|
|
|
@api.depends('product_variant_ids.weight')
|
|
def _compute_weight(self):
|
|
self._compute_template_field_from_variant_field('weight')
|
|
|
|
def _set_weight(self):
|
|
self._set_product_variant_field('weight')
|
|
|
|
def _compute_is_product_variant(self):
|
|
self.is_product_variant = False
|
|
|
|
@api.depends('product_variant_ids.barcode')
|
|
def _compute_barcode(self):
|
|
self._compute_template_field_from_variant_field('barcode')
|
|
|
|
def _search_barcode(self, operator, value):
|
|
subquery = self.with_context(active_test=False)._search([
|
|
('product_variant_ids.barcode', operator, value),
|
|
])
|
|
return [('id', 'in', subquery)]
|
|
|
|
def _set_barcode(self):
|
|
self._set_product_variant_field('barcode')
|
|
|
|
@api.model
|
|
def _get_weight_uom_id_from_ir_config_parameter(self):
|
|
""" Get the unit of measure to interpret the `weight` field. By default, we considerer
|
|
that weights are expressed in kilograms. Users can configure to express them in pounds
|
|
by adding an ir.config_parameter record with "product.product_weight_in_lbs" as key
|
|
and "1" as value.
|
|
"""
|
|
product_weight_in_lbs_param = self.env['ir.config_parameter'].sudo().get_param('product.weight_in_lbs')
|
|
if product_weight_in_lbs_param == '1':
|
|
return self.env.ref('uom.product_uom_lb')
|
|
else:
|
|
return self.env.ref('uom.product_uom_kgm')
|
|
|
|
@api.model
|
|
def _get_length_uom_id_from_ir_config_parameter(self):
|
|
""" Get the unit of measure to interpret the `length`, 'width', 'height' field.
|
|
By default, we considerer that length are expressed in millimeters. Users can configure
|
|
to express them in feet by adding an ir.config_parameter record with "product.volume_in_cubic_feet"
|
|
as key and "1" as value.
|
|
"""
|
|
product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet')
|
|
if product_length_in_feet_param == '1':
|
|
return self.env.ref('uom.product_uom_foot')
|
|
else:
|
|
return self.env.ref('uom.product_uom_millimeter')
|
|
|
|
@api.model
|
|
def _get_volume_uom_id_from_ir_config_parameter(self):
|
|
""" Get the unit of measure to interpret the `volume` field. By default, we consider
|
|
that volumes are expressed in cubic meters. Users can configure to express them in cubic feet
|
|
by adding an ir.config_parameter record with "product.volume_in_cubic_feet" as key
|
|
and "1" as value.
|
|
"""
|
|
product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet')
|
|
if product_length_in_feet_param == '1':
|
|
return self.env.ref('uom.product_uom_cubic_foot')
|
|
else:
|
|
return self.env.ref('uom.product_uom_cubic_meter')
|
|
|
|
@api.model
|
|
def _get_weight_uom_name_from_ir_config_parameter(self):
|
|
return self._get_weight_uom_id_from_ir_config_parameter().display_name
|
|
|
|
@api.model
|
|
def _get_length_uom_name_from_ir_config_parameter(self):
|
|
return self._get_length_uom_id_from_ir_config_parameter().display_name
|
|
|
|
@api.model
|
|
def _get_volume_uom_name_from_ir_config_parameter(self):
|
|
return self._get_volume_uom_id_from_ir_config_parameter().display_name
|
|
|
|
@api.depends('type')
|
|
def _compute_weight_uom_name(self):
|
|
self.weight_uom_name = self._get_weight_uom_name_from_ir_config_parameter()
|
|
|
|
@api.depends('type')
|
|
def _compute_volume_uom_name(self):
|
|
self.volume_uom_name = self._get_volume_uom_name_from_ir_config_parameter()
|
|
|
|
@api.depends('product_variant_ids.product_tmpl_id')
|
|
def _compute_product_variant_count(self):
|
|
for template in self:
|
|
template.product_variant_count = len(template.product_variant_ids)
|
|
|
|
@api.onchange('default_code')
|
|
def _onchange_default_code(self):
|
|
if not self.default_code:
|
|
return
|
|
|
|
domain = [('default_code', '=', self.default_code)]
|
|
if self.id.origin:
|
|
domain.append(('id', '!=', self.id.origin))
|
|
|
|
if self.env['product.template'].search(domain, limit=1):
|
|
return {'warning': {
|
|
'title': _("Note:"),
|
|
'message': _("The Internal Reference '%s' already exists.", self.default_code),
|
|
}}
|
|
|
|
@api.depends('product_variant_ids.default_code')
|
|
def _compute_default_code(self):
|
|
self._compute_template_field_from_variant_field('default_code')
|
|
|
|
def _set_default_code(self):
|
|
self._set_product_variant_field('default_code')
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.packaging_ids')
|
|
def _compute_packaging_ids(self):
|
|
for p in self:
|
|
if len(p.product_variant_ids) == 1:
|
|
p.packaging_ids = p.product_variant_ids.packaging_ids
|
|
else:
|
|
p.packaging_ids = False
|
|
|
|
def _set_packaging_ids(self):
|
|
for p in self:
|
|
if len(p.product_variant_ids) == 1:
|
|
p.product_variant_ids.packaging_ids = p.packaging_ids
|
|
|
|
@api.depends('type')
|
|
def _compute_product_tooltip(self):
|
|
for record in self:
|
|
if record.type == 'consu':
|
|
record.product_tooltip = _(
|
|
"Consumables are physical products for which you don't manage the inventory "
|
|
"level: they are always available."
|
|
)
|
|
else:
|
|
record.product_tooltip = ""
|
|
|
|
def _detailed_type_mapping(self):
|
|
return {}
|
|
|
|
@api.depends('detailed_type')
|
|
def _compute_type(self):
|
|
type_mapping = self._detailed_type_mapping()
|
|
for record in self:
|
|
record.type = type_mapping.get(record.detailed_type, record.detailed_type)
|
|
|
|
@api.constrains('type', 'detailed_type')
|
|
def _constrains_detailed_type(self):
|
|
type_mapping = self._detailed_type_mapping()
|
|
for record in self:
|
|
if record.type != type_mapping.get(record.detailed_type, record.detailed_type):
|
|
raise ValidationError(_("The Type of this product doesn't match the Detailed Type"))
|
|
|
|
@api.constrains('uom_id', 'uom_po_id')
|
|
def _check_uom(self):
|
|
if any(template.uom_id and template.uom_po_id and template.uom_id.category_id != template.uom_po_id.category_id for template in self):
|
|
raise ValidationError(_('The default Unit of Measure and the purchase Unit of Measure must be in the same category.'))
|
|
|
|
@api.onchange('uom_id')
|
|
def _onchange_uom_id(self):
|
|
if self.uom_id:
|
|
self.uom_po_id = self.uom_id.id
|
|
|
|
@api.onchange('uom_po_id')
|
|
def _onchange_uom(self):
|
|
if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id:
|
|
self.uom_po_id = self.uom_id
|
|
|
|
@api.onchange('type')
|
|
def _onchange_type(self):
|
|
# Do nothing but needed for inheritance
|
|
return {}
|
|
|
|
def _sanitize_vals(self, vals):
|
|
"""Sanitize vales for writing/creating product templates and variants.
|
|
|
|
Values need to be sanitized to keep values synchronized, and to be able to preprocess the
|
|
vals in extensions of create/write.
|
|
:param vals: create/write values dictionary
|
|
"""
|
|
if 'type' in vals and 'detailed_type' not in vals:
|
|
if vals['type'] not in self.mapped('type'):
|
|
vals['detailed_type'] = vals['type']
|
|
if 'detailed_type' in vals and 'type' not in vals:
|
|
type_mapping = self._detailed_type_mapping()
|
|
vals['type'] = type_mapping.get(vals['detailed_type'], vals['detailed_type'])
|
|
|
|
def _get_related_fields_variant_template(self):
|
|
""" Return a list of fields present on template and variants models and that are related"""
|
|
return ['barcode', 'default_code', 'standard_price', 'volume', 'weight', 'packaging_ids', 'product_properties']
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date'''
|
|
for vals in vals_list:
|
|
self._sanitize_vals(vals)
|
|
templates = super(ProductTemplate, self).create(vals_list)
|
|
if self._context.get("create_product_product", True):
|
|
templates._create_variant_ids()
|
|
|
|
# This is needed to set given values to first variant after creation
|
|
for template, vals in zip(templates, vals_list):
|
|
related_vals = {}
|
|
for field_name in self._get_related_fields_variant_template():
|
|
if vals.get(field_name):
|
|
related_vals[field_name] = vals[field_name]
|
|
if related_vals:
|
|
template.write(related_vals)
|
|
|
|
return templates
|
|
|
|
def write(self, vals):
|
|
self._sanitize_vals(vals)
|
|
if 'uom_id' in vals or 'uom_po_id' in vals:
|
|
uom_id = self.env['uom.uom'].browse(vals.get('uom_id')) or self.uom_id
|
|
uom_po_id = self.env['uom.uom'].browse(vals.get('uom_po_id')) or self.uom_po_id
|
|
if uom_id and uom_po_id and uom_id.category_id != uom_po_id.category_id:
|
|
vals['uom_po_id'] = uom_id.id
|
|
res = super(ProductTemplate, self).write(vals)
|
|
if self._context.get("create_product_product", True) and 'attribute_line_ids' in vals or (vals.get('active') and len(self.product_variant_ids) == 0):
|
|
self._create_variant_ids()
|
|
if 'active' in vals and not vals.get('active'):
|
|
self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')})
|
|
if 'image_1920' in vals:
|
|
self.env['product.product'].invalidate_model([
|
|
'image_1920',
|
|
'image_1024',
|
|
'image_512',
|
|
'image_256',
|
|
'image_128',
|
|
'can_image_1024_be_zoomed',
|
|
])
|
|
return res
|
|
|
|
@api.returns('self', lambda value: value.id)
|
|
def copy(self, default=None):
|
|
# TDE FIXME: should probably be copy_data
|
|
self.ensure_one()
|
|
if default is None:
|
|
default = {}
|
|
if 'name' not in default:
|
|
default['name'] = _("%s (copy)", self.name)
|
|
return super(ProductTemplate, self).copy(default=default)
|
|
|
|
@api.depends('name', 'default_code')
|
|
def _compute_display_name(self):
|
|
for template in self:
|
|
template.display_name = '{}{}'.format(template.default_code and '[%s] ' % template.default_code or '', template.name)
|
|
|
|
@api.model
|
|
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
|
|
# Only use the product.product heuristics if there is a search term and the domain
|
|
# does not specify a match on `product.template` IDs.
|
|
domain = domain or []
|
|
if not name or any(term[0] == 'id' for term in domain):
|
|
return super()._name_search(name, domain, operator, limit, order)
|
|
|
|
Product = self.env['product.product']
|
|
templates = self.browse()
|
|
while True:
|
|
extra = templates and [('product_tmpl_id', 'not in', templates.ids)] or []
|
|
# Product._name_search has default value limit=100
|
|
# So, we either use that value or override it to None to fetch all products at once
|
|
products_ids = Product._name_search(name, domain + extra, operator, limit=None)
|
|
products = Product.browse(products_ids)
|
|
new_templates = products.product_tmpl_id
|
|
if new_templates & templates:
|
|
"""Product._name_search can bypass the domain we passed (search on supplier info).
|
|
If this happens, an infinite loop will occur."""
|
|
break
|
|
templates |= new_templates
|
|
if (not products) or (limit and (len(templates) > limit)):
|
|
break
|
|
|
|
searched_ids = set(templates.ids)
|
|
# some product.templates do not have product.products yet (dynamic variants configuration),
|
|
# we need to add the base _name_search to the results
|
|
tmpl_without_variant_ids = []
|
|
# Useless if variants is not set up as no tmpl_without_variant_ids could exist.
|
|
if self.env.user.has_group('product.group_product_variant') and (not limit or len(searched_ids) < limit):
|
|
# The ORM has to be bypassed because it would require a NOT IN which is inefficient.
|
|
self.env['product.product'].flush_model(['product_tmpl_id', 'active'])
|
|
tmpl_without_variant_ids = self.env['product.template']._search([], order='id')
|
|
tmpl_without_variant_ids.add_where("""
|
|
NOT EXISTS (
|
|
SELECT product_tmpl_id
|
|
FROM product_product
|
|
WHERE product_product.active = true
|
|
AND product_template.id = product_product.product_tmpl_id
|
|
)
|
|
""")
|
|
if tmpl_without_variant_ids:
|
|
domain2 = expression.AND([domain, [('id', 'in', tmpl_without_variant_ids)]])
|
|
searched_ids |= set(super()._name_search(name, domain2, operator, limit, order))
|
|
|
|
# re-apply product.template order + display_name
|
|
domain = [('id', 'in', list(searched_ids))]
|
|
return super()._name_search('', domain, 'ilike', limit, order)
|
|
|
|
#=== ACTION METHODS ===#
|
|
|
|
def action_open_label_layout(self):
|
|
action = self.env['ir.actions.act_window']._for_xml_id('product.action_open_label_layout')
|
|
action['context'] = {'default_product_tmpl_ids': self.ids}
|
|
return action
|
|
|
|
def open_pricelist_rules(self):
|
|
self.ensure_one()
|
|
domain = ['|',
|
|
('product_tmpl_id', '=', self.id),
|
|
('product_id', 'in', self.product_variant_ids.ids)]
|
|
return {
|
|
'name': _('Price Rules'),
|
|
'view_mode': 'tree,form',
|
|
'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')],
|
|
'res_model': 'product.pricelist.item',
|
|
'type': 'ir.actions.act_window',
|
|
'target': 'current',
|
|
'domain': domain,
|
|
'context': {
|
|
'default_product_tmpl_id': self.id,
|
|
'default_applied_on': '1_product',
|
|
'product_without_variants': self.product_variant_count == 1,
|
|
'search_default_visible': True,
|
|
},
|
|
}
|
|
|
|
def action_open_documents(self):
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('Documents'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'product.document',
|
|
'view_mode': 'kanban,tree,form',
|
|
'context': {
|
|
'default_res_model': self._name,
|
|
'default_res_id': self.id,
|
|
'default_company_id': self.company_id.id,
|
|
},
|
|
'domain': [
|
|
'|',
|
|
'&', ('res_model', '=', 'product.template'), ('res_id', '=', self.id),
|
|
'&',
|
|
('res_model', '=', 'product.product'),
|
|
('res_id', 'in', self.product_variant_ids.ids),
|
|
],
|
|
'target': 'current',
|
|
'help': """
|
|
<p class="o_view_nocontent_smiling_face">
|
|
%s
|
|
</p>
|
|
<p>
|
|
%s
|
|
<br/>
|
|
%s
|
|
</p>
|
|
<p>
|
|
<a class="oe_link" href="https://www.odoo.com/documentation/17.0/_downloads/5f0840ed187116c425fdac2ab4b592e1/pdfquotebuilderexamples.zip">
|
|
%s
|
|
</a>
|
|
</p>
|
|
""" % (
|
|
_("Upload files to your product"),
|
|
_("Use this feature to store any files you would like to share with your customers"),
|
|
_("(e.g: product description, ebook, legal notice, ...)."),
|
|
_("Download examples")
|
|
)
|
|
}
|
|
|
|
#=== BUSINESS METHODS ===#
|
|
|
|
def _get_product_price_context(self, combination):
|
|
self.ensure_one()
|
|
res = {}
|
|
|
|
current_attributes_price_extra = [
|
|
ptav.price_extra for ptav in combination.filtered(
|
|
lambda ptav:
|
|
ptav.price_extra
|
|
and ptav.product_tmpl_id == self
|
|
)
|
|
]
|
|
if current_attributes_price_extra:
|
|
res['current_attributes_price_extra'] = tuple(current_attributes_price_extra)
|
|
|
|
return res
|
|
|
|
def _get_attributes_extra_price(self):
|
|
self.ensure_one()
|
|
|
|
return sum(self.env.context.get('current_attributes_price_extra', []))
|
|
|
|
def _price_compute(self, price_type, uom=None, currency=None, company=None, date=False):
|
|
company = company or self.env.company
|
|
date = date or fields.Date.context_today(self)
|
|
|
|
self = self.with_company(company)
|
|
if price_type == 'standard_price':
|
|
# standard_price field can only be seen by users in base.group_user
|
|
# Thus, in order to compute the sale price from the cost for users not in this group
|
|
# We fetch the standard price as the superuser
|
|
self = self.sudo()
|
|
|
|
prices = dict.fromkeys(self.ids, 0.0)
|
|
for template in self:
|
|
price = template[price_type] or 0.0
|
|
price_currency = template.currency_id
|
|
if price_type == 'standard_price':
|
|
if not price and template.product_variant_ids:
|
|
price = template.product_variant_ids[0].standard_price
|
|
price_currency = template.cost_currency_id
|
|
elif price_type == 'list_price':
|
|
price += template._get_attributes_extra_price()
|
|
|
|
if uom:
|
|
price = template.uom_id._compute_price(price, uom)
|
|
|
|
# Convert from current user company currency to asked one
|
|
# This is right cause a field cannot be in more than one currency
|
|
if currency:
|
|
price = price_currency._convert(price, currency, company, date)
|
|
|
|
prices[template.id] = price
|
|
return prices
|
|
|
|
def _create_variant_ids(self):
|
|
if not self:
|
|
return
|
|
self.env.flush_all()
|
|
Product = self.env["product.product"]
|
|
|
|
variants_to_create = []
|
|
variants_to_activate = Product
|
|
variants_to_unlink = Product
|
|
|
|
for tmpl_id in self:
|
|
lines_without_no_variants = tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes()
|
|
|
|
all_variants = tmpl_id.with_context(active_test=False).product_variant_ids.sorted(lambda p: (p.active, -p.id))
|
|
|
|
current_variants_to_create = []
|
|
current_variants_to_activate = Product
|
|
|
|
# adding an attribute with only one value should not recreate product
|
|
# write this attribute on every product to make sure we don't lose them
|
|
single_value_lines = lines_without_no_variants.filtered(lambda ptal: len(ptal.product_template_value_ids._only_active()) == 1)
|
|
if single_value_lines:
|
|
for variant in all_variants:
|
|
combination = variant.product_template_attribute_value_ids | single_value_lines.product_template_value_ids._only_active()
|
|
# Do not add single value if the resulting combination would
|
|
# be invalid anyway.
|
|
if (
|
|
len(combination) == len(lines_without_no_variants) and
|
|
combination.attribute_line_id == lines_without_no_variants
|
|
):
|
|
variant.product_template_attribute_value_ids = combination
|
|
|
|
# Set containing existing `product.template.attribute.value` combination
|
|
existing_variants = {
|
|
variant.product_template_attribute_value_ids: variant for variant in all_variants
|
|
}
|
|
|
|
# Determine which product variants need to be created based on the attribute
|
|
# configuration. If any attribute is set to generate variants dynamically, skip the
|
|
# process.
|
|
# Technical note: if there is no attribute, a variant is still created because
|
|
# 'not any([])' and 'set([]) not in set([])' are True.
|
|
if not tmpl_id.has_dynamic_attributes():
|
|
# Iterator containing all possible `product.template.attribute.value` combination
|
|
# The iterator is used to avoid MemoryError in case of a huge number of combination.
|
|
all_combinations = itertools.product(*[
|
|
ptal.product_template_value_ids._only_active() for ptal in lines_without_no_variants
|
|
])
|
|
# For each possible variant, create if it doesn't exist yet.
|
|
for combination_tuple in all_combinations:
|
|
combination = self.env['product.template.attribute.value'].concat(*combination_tuple)
|
|
is_combination_possible = tmpl_id._is_combination_possible_by_config(combination, ignore_no_variant=True)
|
|
if not is_combination_possible:
|
|
continue
|
|
if combination in existing_variants:
|
|
current_variants_to_activate += existing_variants[combination]
|
|
else:
|
|
current_variants_to_create.append(tmpl_id._prepare_variant_values(combination))
|
|
variant_limit = self.env['ir.config_parameter'].sudo().get_param('product.dynamic_variant_limit', 1000)
|
|
if len(current_variants_to_create) > int(variant_limit):
|
|
raise UserError(_(
|
|
'The number of variants to generate is above allowed limit. '
|
|
'You should either not generate variants for each combination or generate them on demand from the sales order. '
|
|
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
|
|
variants_to_create += current_variants_to_create
|
|
variants_to_activate += current_variants_to_activate
|
|
|
|
else:
|
|
for variant in existing_variants.values():
|
|
is_combination_possible = self._is_combination_possible_by_config(
|
|
combination=variant.product_template_attribute_value_ids,
|
|
ignore_no_variant=True,
|
|
)
|
|
if is_combination_possible:
|
|
current_variants_to_activate += variant
|
|
variants_to_activate += current_variants_to_activate
|
|
|
|
variants_to_unlink += all_variants - current_variants_to_activate
|
|
|
|
if variants_to_activate:
|
|
variants_to_activate.write({'active': True})
|
|
if variants_to_create:
|
|
Product.create(variants_to_create)
|
|
if variants_to_unlink:
|
|
variants_to_unlink._unlink_or_archive()
|
|
# prevent change if exclusion deleted template by deleting last variant
|
|
if self.exists() != self:
|
|
raise UserError(_("This configuration of product attributes, values, and exclusions would lead to no possible variant. Please archive or delete your product directly if intended."))
|
|
|
|
# prefetched o2m have to be reloaded (because of active_test)
|
|
# (eg. product.template: product_variant_ids)
|
|
# We can't rely on existing invalidate because of the savepoint
|
|
# in _unlink_or_archive.
|
|
self.env.flush_all()
|
|
self.env.invalidate_all()
|
|
return True
|
|
|
|
def _prepare_variant_values(self, combination):
|
|
self.ensure_one()
|
|
return {
|
|
'product_tmpl_id': self.id,
|
|
'product_template_attribute_value_ids': [(6, 0, combination.ids)],
|
|
'active': self.active
|
|
}
|
|
|
|
def has_dynamic_attributes(self):
|
|
"""Return whether this `product.template` has at least one dynamic
|
|
attribute.
|
|
|
|
:return: True if at least one dynamic attribute, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
self.ensure_one()
|
|
return any(a.create_variant == 'dynamic' for a in self.valid_product_template_attribute_line_ids.attribute_id)
|
|
|
|
@api.depends('attribute_line_ids.value_ids')
|
|
def _compute_valid_product_template_attribute_line_ids(self):
|
|
"""A product template attribute line is considered valid if it has at
|
|
least one possible value.
|
|
|
|
Those with only one value are considered valid, even though they should
|
|
not appear on the configurator itself (unless they have an is_custom
|
|
value to input), indeed single value attributes can be used to filter
|
|
products among others based on that attribute/value.
|
|
"""
|
|
for record in self:
|
|
record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
|
|
|
|
def _get_possible_variants(self, parent_combination=None):
|
|
"""Return the existing variants that are possible.
|
|
|
|
For dynamic attributes, it will only return the variants that have been
|
|
created already.
|
|
|
|
If there are a lot of variants, this method might be slow. Even if there
|
|
aren't too many variants, for performance reasons, do not call this
|
|
method in a loop over the product templates.
|
|
|
|
Therefore this method has a very restricted reasonable use case and you
|
|
should strongly consider doing things differently if you consider using
|
|
this method.
|
|
|
|
:param parent_combination: combination from which `self` is an
|
|
optional or accessory product.
|
|
:type parent_combination: recordset `product.template.attribute.value`
|
|
|
|
:return: the existing variants that are possible.
|
|
:rtype: recordset of `product.product`
|
|
"""
|
|
self.ensure_one()
|
|
return self.product_variant_ids.filtered(lambda p: p._is_variant_possible(parent_combination))
|
|
|
|
def _get_attribute_exclusions(
|
|
self, parent_combination=None, parent_name=None, combination_ids=None
|
|
):
|
|
"""Return the list of attribute exclusions of a product.
|
|
|
|
:param parent_combination: the combination from which
|
|
`self` is an optional or accessory product. Indeed exclusions
|
|
rules on one product can concern another product.
|
|
:type parent_combination: recordset `product.template.attribute.value`
|
|
:param parent_name: the name of the parent product combination.
|
|
:type parent_name: str
|
|
:param list combination: The combination of the product, as a
|
|
list of `product.template.attribute.value` ids.
|
|
|
|
:return: dict of exclusions
|
|
- exclusions: from this product itself
|
|
- archived_combinations: list of archived combinations
|
|
- parent_combination: ids of the given parent_combination
|
|
- parent_exclusions: from the parent_combination
|
|
- parent_product_name: the name of the parent product if any, used in the interface
|
|
to explain why some combinations are not available.
|
|
(e.g: Not available with Customizable Desk (Legs: Steel))
|
|
- mapped_attribute_names: the name of every attribute values based on their id,
|
|
used to explain in the interface why that combination is not available
|
|
(e.g: Not available with Color: Black)
|
|
"""
|
|
self.ensure_one()
|
|
parent_combination = parent_combination or self.env['product.template.attribute.value']
|
|
archived_products = self.with_context(active_test=False).product_variant_ids.filtered(lambda l: not l.active)
|
|
active_combinations = set(tuple(product.product_template_attribute_value_ids.ids) for product in self.product_variant_ids)
|
|
return {
|
|
'exclusions': self._complete_inverse_exclusions(
|
|
self._get_own_attribute_exclusions(combination_ids=combination_ids)
|
|
),
|
|
'archived_combinations': list(set(
|
|
tuple(product.product_template_attribute_value_ids.ids)
|
|
for product in archived_products
|
|
if product.product_template_attribute_value_ids and all(
|
|
ptav.ptav_active or combination_ids and ptav.id in combination_ids
|
|
for ptav in product.product_template_attribute_value_ids
|
|
)
|
|
) - active_combinations),
|
|
'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination),
|
|
'parent_combination': parent_combination.ids,
|
|
'parent_product_name': parent_name,
|
|
'mapped_attribute_names': self._get_mapped_attribute_names(parent_combination),
|
|
}
|
|
|
|
@api.model
|
|
def _complete_inverse_exclusions(self, exclusions):
|
|
"""Will complete the dictionnary of exclusions with their respective inverse
|
|
e.g: Black excludes XL and L
|
|
-> XL excludes Black
|
|
-> L excludes Black"""
|
|
result = dict(exclusions)
|
|
for key, value in exclusions.items():
|
|
for exclusion in value:
|
|
if exclusion in result and key not in result[exclusion]:
|
|
result[exclusion].append(key)
|
|
else:
|
|
result[exclusion] = [key]
|
|
|
|
return result
|
|
|
|
def _get_own_attribute_exclusions(self, combination_ids=None):
|
|
"""Get exclusions coming from the current template.
|
|
|
|
:param list combination: The combination of the product, as a
|
|
list of `product.template.attribute.value` ids.
|
|
Dictionnary, each product template attribute value is a key, and for each of them
|
|
the value is an array with the other ptav that they exclude (empty if no exclusion).
|
|
"""
|
|
self.ensure_one()
|
|
product_template_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids
|
|
return {
|
|
ptav.id: [
|
|
value.id
|
|
for filter_line in ptav.exclude_for.filtered(
|
|
lambda filter_line: filter_line.product_tmpl_id == self
|
|
) for value in filter_line.value_ids if value.ptav_active
|
|
]
|
|
for ptav in product_template_attribute_values if (
|
|
ptav.ptav_active or combination_ids and ptav.id in combination_ids
|
|
)
|
|
}
|
|
|
|
def _get_parent_attribute_exclusions(self, parent_combination):
|
|
"""Get exclusions coming from the parent combination.
|
|
|
|
Dictionnary, each parent's ptav is a key, and for each of them the value is
|
|
an array with the other ptav that are excluded because of the parent.
|
|
"""
|
|
self.ensure_one()
|
|
if not parent_combination:
|
|
return {}
|
|
|
|
result = {}
|
|
for product_attribute_value in parent_combination:
|
|
for filter_line in product_attribute_value.exclude_for.filtered(
|
|
lambda filter_line: filter_line.product_tmpl_id == self
|
|
):
|
|
# Some exclusions don't have attribute value. This means that the template is not
|
|
# compatible with the parent combination. If such an exclusion is found, it means that all
|
|
# attribute values are excluded.
|
|
if filter_line.value_ids:
|
|
result[product_attribute_value.id] = filter_line.value_ids.ids
|
|
else:
|
|
result[product_attribute_value.id] = filter_line.product_tmpl_id.mapped('attribute_line_ids.product_template_value_ids').ids
|
|
|
|
return result
|
|
|
|
def _get_mapped_attribute_names(self, parent_combination=None):
|
|
""" The name of every attribute values based on their id,
|
|
used to explain in the interface why that combination is not available
|
|
(e.g: Not available with Color: Black).
|
|
|
|
It contains both attribute value names from this product and from
|
|
the parent combination if provided.
|
|
"""
|
|
self.ensure_one()
|
|
all_product_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids
|
|
if parent_combination:
|
|
all_product_attribute_values |= parent_combination
|
|
|
|
return {
|
|
attribute_value.id: attribute_value.display_name
|
|
for attribute_value in all_product_attribute_values
|
|
}
|
|
|
|
def _is_combination_possible_by_config(self, combination, ignore_no_variant=False):
|
|
"""Return whether the given combination is possible according to the config of attributes on the template
|
|
|
|
:param combination: the combination to check for possibility
|
|
:type combination: recordset `product.template.attribute.value`
|
|
|
|
:param ignore_no_variant: whether no_variant attributes should be ignored
|
|
:type ignore_no_variant: bool
|
|
|
|
:return: wether the given combination is possible according to the config of attributes on the template
|
|
:rtype: bool
|
|
"""
|
|
self.ensure_one()
|
|
|
|
attribute_lines = self.valid_product_template_attribute_line_ids
|
|
|
|
if ignore_no_variant:
|
|
attribute_lines = attribute_lines._without_no_variant_attributes()
|
|
|
|
attribute_lines_without_multi = attribute_lines.filtered(
|
|
lambda l: l.attribute_id.display_type != 'multi')
|
|
combination_without_multi = combination.filtered(
|
|
lambda l: l.attribute_line_id.attribute_id.display_type != 'multi')
|
|
|
|
if len(combination_without_multi) != len(attribute_lines_without_multi):
|
|
# number of attribute values passed is different than the
|
|
# configuration of attributes on the template
|
|
return False
|
|
|
|
if attribute_lines_without_multi != combination_without_multi.attribute_line_id:
|
|
# combination has different attributes than the ones configured on the template
|
|
return False
|
|
|
|
if not (attribute_lines.product_template_value_ids._only_active() >= combination):
|
|
# combination has different values than the ones configured on the template
|
|
return False
|
|
|
|
exclusions = self._get_own_attribute_exclusions()
|
|
if exclusions:
|
|
# exclude if the current value is in an exclusion,
|
|
# and the value excluding it is also in the combination
|
|
for ptav in combination:
|
|
for exclusion in exclusions.get(ptav.id):
|
|
if exclusion in combination.ids:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _is_combination_possible(self, combination, parent_combination=None, ignore_no_variant=False):
|
|
"""
|
|
The combination is possible if it is not excluded by any rule
|
|
coming from the current template, not excluded by any rule from the
|
|
parent_combination (if given), and there should not be any archived
|
|
variant with the exact same combination.
|
|
|
|
If the template does not have any dynamic attribute, the combination
|
|
is also not possible if the matching variant has been deleted.
|
|
|
|
Moreover the attributes of the combination must excatly match the
|
|
attributes allowed on the template.
|
|
|
|
:param combination: the combination to check for possibility
|
|
:type combination: recordset `product.template.attribute.value`
|
|
|
|
:param ignore_no_variant: whether no_variant attributes should be ignored
|
|
:type ignore_no_variant: bool
|
|
|
|
:param parent_combination: combination from which `self` is an
|
|
optional or accessory product.
|
|
:type parent_combination: recordset `product.template.attribute.value`
|
|
|
|
:return: whether the combination is possible
|
|
:rtype: bool
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if not self._is_combination_possible_by_config(combination, ignore_no_variant):
|
|
return False
|
|
|
|
variant = self._get_variant_for_combination(combination)
|
|
|
|
if self.has_dynamic_attributes():
|
|
if variant and not variant.active:
|
|
# dynamic and the variant has been archived
|
|
return False
|
|
else:
|
|
if not variant or not variant.active:
|
|
# not dynamic, the variant has been archived or deleted
|
|
return False
|
|
|
|
parent_exclusions = self._get_parent_attribute_exclusions(parent_combination)
|
|
if parent_exclusions:
|
|
# parent_exclusion are mapped by ptav but here we don't need to know
|
|
# where the exclusion comes from so we loop directly on the dict values
|
|
for exclusions_values in parent_exclusions.values():
|
|
for exclusion in exclusions_values:
|
|
if exclusion in combination.ids:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _get_variant_for_combination(self, combination):
|
|
"""Get the variant matching the combination.
|
|
|
|
All of the values in combination must be present in the variant, and the
|
|
variant should not have more attributes. Ignore the attributes that are
|
|
not supposed to create variants.
|
|
|
|
:param combination: recordset of `product.template.attribute.value`
|
|
|
|
:return: the variant if found, else empty
|
|
:rtype: recordset `product.product`
|
|
"""
|
|
self.ensure_one()
|
|
filtered_combination = combination._without_no_variant_attributes()
|
|
return self.env['product.product'].browse(self._get_variant_id_for_combination(filtered_combination))
|
|
|
|
def _create_product_variant(self, combination, log_warning=False):
|
|
""" Create if necessary and possible and return the product variant
|
|
matching the given combination for this template.
|
|
|
|
It is possible to create only if the template has dynamic attributes
|
|
and the combination itself is possible.
|
|
If we are in this case and the variant already exists but it is
|
|
archived, it is activated instead of being created again.
|
|
|
|
:param combination: the combination for which to get or create variant.
|
|
The combination must contain all necessary attributes, including
|
|
those of type no_variant. Indeed even though those attributes won't
|
|
be included in the variant if newly created, they are needed when
|
|
checking if the combination is possible.
|
|
:type combination: recordset of `product.template.attribute.value`
|
|
|
|
:param log_warning: whether a warning should be logged on fail
|
|
:type log_warning: bool
|
|
|
|
:return: the product variant matching the combination or none
|
|
:rtype: recordset of `product.product`
|
|
"""
|
|
self.ensure_one()
|
|
|
|
Product = self.env['product.product']
|
|
|
|
product_variant = self._get_variant_for_combination(combination)
|
|
if product_variant:
|
|
if not product_variant.active and self.has_dynamic_attributes() and self._is_combination_possible(combination):
|
|
product_variant.active = True
|
|
return product_variant
|
|
|
|
if not self.has_dynamic_attributes():
|
|
if log_warning:
|
|
_logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id))
|
|
return Product
|
|
|
|
if not self._is_combination_possible(combination):
|
|
if log_warning:
|
|
_logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id))
|
|
return Product
|
|
|
|
return Product.sudo().create({
|
|
'product_tmpl_id': self.id,
|
|
'product_template_attribute_value_ids': [(6, 0, combination._without_no_variant_attributes().ids)]
|
|
})
|
|
|
|
def _create_first_product_variant(self, log_warning=False):
|
|
"""Create if necessary and possible and return the first product
|
|
variant for this template.
|
|
|
|
:param log_warning: whether a warning should be logged on fail
|
|
:type log_warning: bool
|
|
|
|
:return: the first product variant or none
|
|
:rtype: recordset of `product.product`
|
|
"""
|
|
return self._create_product_variant(self._get_first_possible_combination(), log_warning)
|
|
|
|
@tools.ormcache('self.id', 'frozenset(filtered_combination.ids)')
|
|
def _get_variant_id_for_combination(self, filtered_combination):
|
|
"""See `_get_variant_for_combination`. This method returns an ID
|
|
so it can be cached.
|
|
|
|
Use sudo because the same result should be cached for all users.
|
|
"""
|
|
self.ensure_one()
|
|
domain = [('product_tmpl_id', '=', self.id)]
|
|
combination_indices_ids = filtered_combination._ids2str()
|
|
|
|
if combination_indices_ids:
|
|
domain = expression.AND([domain, [('combination_indices', '=', combination_indices_ids)]])
|
|
else:
|
|
domain = expression.AND([domain, [('combination_indices', 'in', ['', False])]])
|
|
|
|
return self.env['product.product'].sudo().with_context(active_test=False).search(domain, order='active DESC', limit=1).id
|
|
|
|
@tools.ormcache('self.id')
|
|
def _get_first_possible_variant_id(self):
|
|
"""See `_create_first_product_variant`. This method returns an ID
|
|
so it can be cached."""
|
|
self.ensure_one()
|
|
return self._create_first_product_variant().id
|
|
|
|
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
|
|
"""See `_get_possible_combinations` (one iteration).
|
|
|
|
This method return the same result (empty recordset) if no
|
|
combination is possible at all which would be considered a negative
|
|
result, or if there are no attribute lines on the template in which
|
|
case the "empty combination" is actually a possible combination.
|
|
Therefore the result of this method when empty should be tested
|
|
with `_is_combination_possible` if it's important to know if the
|
|
resulting empty combination is actually possible or not.
|
|
"""
|
|
return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
|
|
|
|
def _cartesian_product(self, product_template_attribute_values_per_line, parent_combination):
|
|
"""
|
|
Generate all possible combination for attributes values (aka cartesian product).
|
|
It is equivalent to itertools.product except it skips invalid partial combinations before they are complete.
|
|
|
|
Imagine the cartesian product of 'A', 'CD' and range(1_000_000) and let's say that 'A' and 'C' are incompatible.
|
|
If you use itertools.product or any normal cartesian product, you'll need to filter out of the final result
|
|
the 1_000_000 combinations that start with 'A' and 'C' . Instead, This implementation will test if 'A' and 'C' are
|
|
compatible before even considering range(1_000_000), skip it and and continue with combinations that start
|
|
with 'A' and 'D'.
|
|
|
|
It's necessary for performance reason because filtering out invalid combinations from standard Cartesian product
|
|
can be extremely slow
|
|
|
|
:param product_template_attribute_values_per_line: the values we want all the possibles combinations of.
|
|
One list of values by attribute line
|
|
:return: a generator of product template attribute value
|
|
"""
|
|
if not product_template_attribute_values_per_line:
|
|
return
|
|
|
|
all_exclusions = {self.env['product.template.attribute.value'].browse(k):
|
|
self.env['product.template.attribute.value'].browse(v) for k, v in
|
|
self._get_own_attribute_exclusions().items()}
|
|
# The following dict uses product template attribute values as keys
|
|
# 0 means the value is acceptable, greater than 0 means it's rejected, it cannot be negative
|
|
# Bear in mind that several values can reject the same value and the latter can only be included in the
|
|
# considered combination if no value rejects it.
|
|
# This dictionary counts how many times each value is rejected.
|
|
# Each time a value is included in the considered combination, the values it rejects are incremented
|
|
# When a value is discarded from the considered combination, the values it rejects are decremented
|
|
current_exclusions = defaultdict(int)
|
|
for exclusion in self._get_parent_attribute_exclusions(parent_combination):
|
|
current_exclusions[self.env['product.template.attribute.value'].browse(exclusion)] += 1
|
|
partial_combination = self.env['product.template.attribute.value']
|
|
|
|
# The following list reflects product_template_attribute_values_per_line
|
|
# For each line, instead of a list of values, it contains the index of the selected value
|
|
# -1 means no value has been picked for the line in the current (partial) combination
|
|
value_index_per_line = [-1] * len(product_template_attribute_values_per_line)
|
|
# determines which line line we're working on
|
|
line_index = 0
|
|
|
|
while True:
|
|
current_line_values = product_template_attribute_values_per_line[line_index]
|
|
current_ptav_index = value_index_per_line[line_index]
|
|
|
|
# For multi-checkbox attribute, the list is empty as we want to start without any selected value
|
|
if not current_line_values:
|
|
if line_index == len(product_template_attribute_values_per_line) - 1:
|
|
# submit combination if we're on the last line
|
|
yield partial_combination
|
|
else:
|
|
line_index += 1
|
|
continue
|
|
|
|
current_ptav = current_line_values[current_ptav_index]
|
|
|
|
# removing exclusions from current_ptav as we're removing it from partial_combination
|
|
if current_ptav_index >= 0:
|
|
for ptav_to_include_back in all_exclusions[current_ptav]:
|
|
current_exclusions[ptav_to_include_back] -= 1
|
|
partial_combination -= current_ptav
|
|
|
|
if current_ptav_index < len(current_line_values) - 1:
|
|
# go to next value of current line
|
|
value_index_per_line[line_index] += 1
|
|
current_line_values = product_template_attribute_values_per_line[line_index]
|
|
current_ptav_index = value_index_per_line[line_index]
|
|
current_ptav = current_line_values[current_ptav_index]
|
|
elif line_index != 0:
|
|
# reset current line, and then go to previous line
|
|
value_index_per_line[line_index] = - 1
|
|
line_index -= 1
|
|
continue
|
|
else:
|
|
# we're done if we must reset first line
|
|
break
|
|
|
|
# adding exclusions from current_ptav as we're incorporating it in partial_combination
|
|
for ptav_to_exclude in all_exclusions[current_ptav]:
|
|
current_exclusions[ptav_to_exclude] += 1
|
|
partial_combination += current_ptav
|
|
|
|
# test if included values excludes current value or if current value exclude included values
|
|
if current_exclusions[current_ptav] or \
|
|
any(intersection in partial_combination for intersection in all_exclusions[current_ptav]):
|
|
continue
|
|
|
|
if line_index == len(product_template_attribute_values_per_line) - 1:
|
|
# submit combination if we're on the last line
|
|
yield partial_combination
|
|
else:
|
|
# else we go to the next line
|
|
line_index += 1
|
|
|
|
def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
|
|
"""Generator returning combinations that are possible, following the
|
|
sequence of attributes and values.
|
|
|
|
See `_is_combination_possible` for what is a possible combination.
|
|
|
|
When encountering an impossible combination, try to change the value
|
|
of attributes by starting with the further regarding their sequences.
|
|
|
|
Ignore attributes that have no values.
|
|
|
|
:param parent_combination: combination from which `self` is an
|
|
optional or accessory product.
|
|
:type parent_combination: recordset `product.template.attribute.value`
|
|
|
|
:param necessary_values: values that must be in the returned combination
|
|
:type necessary_values: recordset of `product.template.attribute.value`
|
|
|
|
:return: the possible combinations
|
|
:rtype: generator of recordset of `product.template.attribute.value`
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if not self.active:
|
|
return _("The product template is archived so no combination is possible.")
|
|
|
|
necessary_values = necessary_values or self.env['product.template.attribute.value']
|
|
necessary_attribute_lines = necessary_values.mapped('attribute_line_id')
|
|
attribute_lines = self.valid_product_template_attribute_line_ids.filtered(
|
|
lambda ptal: ptal not in necessary_attribute_lines)
|
|
|
|
if not attribute_lines and self._is_combination_possible(necessary_values, parent_combination):
|
|
yield necessary_values
|
|
|
|
product_template_attribute_values_per_line = []
|
|
for ptal in attribute_lines:
|
|
if ptal.attribute_id.display_type != 'multi':
|
|
values_to_add = ptal.product_template_value_ids._only_active()
|
|
else:
|
|
values_to_add = self.env['product.template.attribute.value']
|
|
product_template_attribute_values_per_line.append(values_to_add)
|
|
|
|
for partial_combination in self._cartesian_product(product_template_attribute_values_per_line, parent_combination):
|
|
combination = partial_combination + necessary_values
|
|
if self._is_combination_possible(combination, parent_combination):
|
|
yield combination
|
|
|
|
return _("There are no remaining possible combination.")
|
|
|
|
def _get_closest_possible_combination(self, combination):
|
|
"""See `_get_closest_possible_combinations` (one iteration).
|
|
|
|
This method return the same result (empty recordset) if no
|
|
combination is possible at all which would be considered a negative
|
|
result, or if there are no attribute lines on the template in which
|
|
case the "empty combination" is actually a possible combination.
|
|
Therefore the result of this method when empty should be tested
|
|
with `_is_combination_possible` if it's important to know if the
|
|
resulting empty combination is actually possible or not.
|
|
"""
|
|
return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
|
|
|
|
def _get_closest_possible_combinations(self, combination):
|
|
"""Generator returning the possible combinations that are the closest to
|
|
the given combination.
|
|
|
|
If the given combination is incomplete, try to complete it.
|
|
|
|
If the given combination is invalid, try to remove values from it before
|
|
completing it.
|
|
|
|
:param combination: the values to include if they are possible
|
|
:type combination: recordset `product.template.attribute.value`
|
|
|
|
:return: the possible combinations that are including as much
|
|
elements as possible from the given combination.
|
|
:rtype: generator of recordset of product.template.attribute.value
|
|
"""
|
|
while True:
|
|
res = self._get_possible_combinations(necessary_values=combination)
|
|
try:
|
|
# If there is at least one result for the given combination
|
|
# we consider that combination set, and we yield all the
|
|
# possible combinations for it.
|
|
yield(next(res))
|
|
for cur in res:
|
|
yield(cur)
|
|
return _("There are no remaining closest combination.")
|
|
except StopIteration:
|
|
# There are no results for the given combination, we try to
|
|
# progressively remove values from it.
|
|
if not combination:
|
|
return _("There are no possible combination.")
|
|
combination = combination[:-1]
|
|
|
|
def _get_placeholder_filename(self, field):
|
|
image_fields = ['image_%s' % size for size in [1920, 1024, 512, 256, 128]]
|
|
if field in image_fields:
|
|
return 'product/static/img/placeholder_thumbnail.png'
|
|
return super()._get_placeholder_filename(field)
|
|
|
|
def get_single_product_variant(self):
|
|
""" Method used by the product configurator to check if the product is configurable or not.
|
|
|
|
We need to open the product configurator if the product:
|
|
- is configurable (see has_configurable_attributes)
|
|
- has optional products (method is extended in sale to return optional products info)
|
|
|
|
Note: self.ensure_one()
|
|
"""
|
|
self.ensure_one()
|
|
if self.product_variant_count == 1 and not self.has_configurable_attributes:
|
|
return {
|
|
'product_id': self.product_variant_id.id,
|
|
'product_name': self.product_variant_id.display_name,
|
|
}
|
|
return {}
|
|
|
|
@api.model
|
|
def get_empty_list_help(self, help_message):
|
|
self = self.with_context(
|
|
empty_list_help_document_name=_("product"),
|
|
)
|
|
return super(ProductTemplate, self).get_empty_list_help(help_message)
|
|
|
|
@api.model
|
|
def get_import_templates(self):
|
|
return [{
|
|
'label': _('Import Template for Products'),
|
|
'template': '/product/static/xls/product_template.xls'
|
|
}]
|
|
|
|
def get_contextual_price(self, product=None):
|
|
return self._get_contextual_price(product=product)
|
|
|
|
def _get_contextual_price(self, product=None):
|
|
self.ensure_one()
|
|
pricelist = self._get_contextual_pricelist()
|
|
quantity = self.env.context.get('quantity', 1.0)
|
|
uom = self.env['uom.uom'].browse(self.env.context.get('uom'))
|
|
date = self.env.context.get('date')
|
|
return pricelist._get_product_price(product or self, quantity, uom=uom, date=date)
|
|
|
|
def _get_contextual_pricelist(self):
|
|
""" Get the contextual pricelist
|
|
|
|
This method is meant to be overriden in other standard modules.
|
|
"""
|
|
return self.env['product.pricelist'].browse(self.env.context.get('pricelist'))
|