Odoo18-Base/addons/product/models/product_template.py

1354 lines
64 KiB
Python
Raw Permalink Normal View History

2025-03-10 11:12:23 +07:00
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
import logging
from collections import defaultdict
from odoo import api, fields, models, tools, _, SUPERUSER_ID
from odoo.exceptions import ValidationError, RedirectWarning, UserError
from odoo.models import PREFETCH_MAX
from odoo.osv import expression
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
_name = "product.template"
_inherit = ['mail.thread', 'mail.activity.mixin', 'image.mixin']
_description = "Product"
_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="""In Standard Price & AVCO: value of the product (automatically computed in AVCO).
In FIFO: value of the next unit that will leave the stock (automatically computed).
Used to value the product when the purchase cost is not known (e.g. inventory adjustment).
Used to compute margins on sale orders.""")
volume = fields.Float(
'Volume', compute='_compute_volume', inverse='_set_volume', digits='Volume', store=True)
volume_uom_name = fields.Char(string='Volume unit of measure label', compute='_compute_volume_uom_name')
weight = fields.Float(
'Weight', compute='_compute_weight', digits='Stock Weight',
inverse='_set_weight', store=True)
weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name')
sale_ok = fields.Boolean('Can be Sold', default=True)
purchase_ok = fields.Boolean('Can be Purchased', default=True)
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")
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('product.tag', 'product_tag_product_template_rel', string='Product Tags')
def _compute_item_count(self):
for template in self:
# Pricelist item count counts the rules applicable on current template or on its variants.
template.pricelist_item_count = template.env['product.pricelist.item'].search_count([
'|', ('product_tmpl_id', '=', template.id), ('product_id', 'in', template.product_variant_ids.ids)])
@api.depends('image_1920', 'image_1024')
def _compute_can_image_1024_be_zoomed(self):
for template in self:
template.can_image_1024_be_zoomed = template.image_1920 and tools.is_image_size_above(template.image_1920, template.image_1024)
@api.depends('attribute_line_ids', 'attribute_line_ids.value_ids', 'attribute_line_ids.attribute_id.create_variant')
def _compute_has_configurable_attributes(self):
"""A product is considered configurable if:
- It has dynamic attributes
- It has any attribute line with at least 2 attribute values configured
"""
for product in self:
product.has_configurable_attributes = product.has_dynamic_attributes() or any(len(ptal.value_ids) >= 2 for ptal in product.attribute_line_ids)
@api.depends('product_variant_ids')
def _compute_product_variant_id(self):
for p in self:
p.product_variant_id = p.product_variant_ids[:1].id
@api.depends('company_id')
def _compute_currency_id(self):
main_company = self.env['res.company']._get_main_company()
for template in self:
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
@api.depends_context('company')
def _compute_cost_currency_id(self):
self.cost_currency_id = self.env.company.currency_id.id
@api.depends_context('company')
@api.depends('product_variant_ids', 'product_variant_ids.standard_price')
def _compute_standard_price(self):
# Depends on force_company context because standard_price is company_dependent
# on the product_product
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
for template in unique_variants:
template.standard_price = template.product_variant_ids.standard_price
for template in (self - unique_variants):
template.standard_price = 0.0
def _set_standard_price(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.standard_price = template.standard_price
def _search_standard_price(self, operator, value):
products = self.env['product.product'].search([('standard_price', operator, value)], limit=None)
return [('id', 'in', products.mapped('product_tmpl_id').ids)]
@api.depends('product_variant_ids', 'product_variant_ids.volume')
def _compute_volume(self):
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
for template in unique_variants:
template.volume = template.product_variant_ids.volume
for template in (self - unique_variants):
template.volume = 0.0
def _set_volume(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.volume = template.volume
@api.depends('product_variant_ids', 'product_variant_ids.weight')
def _compute_weight(self):
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
for template in unique_variants:
template.weight = template.product_variant_ids.weight
for template in (self - unique_variants):
template.weight = 0.0
def _compute_is_product_variant(self):
self.is_product_variant = False
@api.depends('product_variant_ids.barcode')
def _compute_barcode(self):
self.barcode = False
for template in self:
# TODO master: update product_variant_count depends and use it instead
variant_count = len(template.product_variant_ids)
if variant_count == 1:
template.barcode = template.product_variant_ids.barcode
elif variant_count == 0:
archived_variants = template.with_context(active_test=False).product_variant_ids
if len(archived_variants) == 1:
template.barcode = archived_variants.barcode
def _search_barcode(self, operator, value):
query = self.with_context(active_test=False)._search([('product_variant_ids.barcode', operator, value)])
return [('id', 'in', query)]
def _set_barcode(self):
variant_count = len(self.product_variant_ids)
if variant_count == 1:
self.product_variant_ids.barcode = self.barcode
elif variant_count == 0:
archived_variants = self.with_context(active_test=False).product_variant_ids
if len(archived_variants) == 1:
archived_variants.barcode = self.barcode
@api.model
def _get_weight_uom_id_from_ir_config_parameter(self):
""" Get the unit of measure to interpret the `weight` field. By default, we considerer
that weights are expressed in kilograms. Users can configure to express them in pounds
by adding an ir.config_parameter record with "product.product_weight_in_lbs" as key
and "1" as value.
"""
product_weight_in_lbs_param = self.env['ir.config_parameter'].sudo().get_param('product.weight_in_lbs')
if product_weight_in_lbs_param == '1':
return self.env.ref('uom.product_uom_lb')
else:
return self.env.ref('uom.product_uom_kgm')
@api.model
def _get_length_uom_id_from_ir_config_parameter(self):
""" Get the unit of measure to interpret the `length`, 'width', 'height' field.
By default, we considerer that length are expressed in 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
def _compute_weight_uom_name(self):
self.weight_uom_name = self._get_weight_uom_name_from_ir_config_parameter()
def _compute_volume_uom_name(self):
self.volume_uom_name = self._get_volume_uom_name_from_ir_config_parameter()
def _set_weight(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.weight = template.weight
@api.depends('product_variant_ids.product_tmpl_id')
def _compute_product_variant_count(self):
for template in self:
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', 'product_variant_ids.default_code')
def _compute_default_code(self):
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
for template in unique_variants:
template.default_code = template.product_variant_ids.default_code
for template in (self - unique_variants):
template.default_code = False
def _set_default_code(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.default_code = template.default_code
@api.depends('product_variant_ids', 'product_variant_ids.packaging_ids')
def _compute_packaging_ids(self):
for p in self:
if len(p.product_variant_ids) == 1:
p.packaging_ids = p.product_variant_ids.packaging_ids
else:
p.packaging_ids = False
def _set_packaging_ids(self):
for p in self:
if len(p.product_variant_ids) == 1:
p.product_variant_ids.packaging_ids = p.packaging_ids
@api.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']
@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 "create_product_product" not in self._context:
templates._create_variant_ids()
# This is needed to set given values to first variant after creation
for template, vals in zip(templates, vals_list):
related_vals = {}
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 '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)
res = super().copy(default=default)
# Since we don't copy the product template attribute values, we need to match the extra prices.
for ptal, copied_ptal in zip(self.attribute_line_ids, res.attribute_line_ids):
for ptav, copied_ptav in zip(ptal.product_template_value_ids, copied_ptal.product_template_value_ids):
if not ptav.price_extra:
continue
# security check
if ptav.attribute_id == copied_ptav.attribute_id and ptav.product_attribute_value_id == copied_ptav.product_attribute_value_id:
copied_ptav.price_extra = ptav.price_extra
return res
def name_get(self):
# Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields
self.browse(self.ids).read(['name', 'default_code'])
return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name))
for template in self]
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
# Only use the product.product heuristics if there is a search term and the domain
# does not specify a match on `product.template` IDs.
if not name or any(term[0] == 'id' for term in (args or [])):
return super(ProductTemplate, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
Product = self.env['product.product']
templates = self.browse([])
while True:
domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or []
args = args if args is not None else []
# Pathological case: there is no limit, so we'll need to search on all products.
# We iteratively _name_search with a larger bound, but not unbounded to avoid
# performance regressions or OOM errors while manipulating extremely large list of ids.
# For other cases, we use PREFETCH_MAX as an upper bound.
search_limit = PREFETCH_MAX * 10 if not limit else PREFETCH_MAX
products_ids = Product._name_search(name, args+domain, operator=operator, limit=search_limit, name_get_uid=name_get_uid)
products = Product.browse(products_ids)
new_templates = products.mapped('product_tmpl_id')
if new_templates & templates:
"""Product._name_search can bypass the domain we passed (search on supplier info).
If this happens, an infinite loop will occur."""
break
templates |= new_templates
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:
domain = expression.AND([args or [], [('id', 'in', tmpl_without_variant_ids)]])
searched_ids |= set(super(ProductTemplate, self)._name_search(
name,
args=domain,
operator=operator,
limit=limit,
name_get_uid=name_get_uid))
# re-apply product.template order + name_get
return super(ProductTemplate, self)._name_search(
'', args=[('id', 'in', list(searched_ids))],
operator='ilike', limit=limit, name_get_uid=name_get_uid)
def 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')],
'res_model': 'product.pricelist.item',
'type': 'ir.actions.act_window',
'target': 'current',
'domain': domain,
'context': {
'default_product_tmpl_id': self.id,
'default_applied_on': '1_product',
'product_without_variants': self.product_variant_count == 1,
},
}
def price_compute(self, price_type, uom=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
# yes, there can be attribute values for product template if it's not a variant YET
# (see field product.attribute create_variant)
if price_type == 'list_price' and self._context.get('current_attributes_price_extra'):
# we have a list of price_extra that comes from the attribute values, we need to sum all that
price += sum(self._context.get('current_attributes_price_extra'))
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))
if len(current_variants_to_create) > 1000:
raise UserError(_(
'The number of variants to generate is too high. '
'You should either not generate variants for each combination or generate them on demand from the sales order. '
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
variants_to_create += current_variants_to_create
variants_to_activate += current_variants_to_activate
else:
for variant in existing_variants.values():
is_combination_possible = tmpl_id._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):
"""Return the list of attribute exclusions of a product.
:param parent_combination: the combination from which
`self` is an optional or accessory product. Indeed exclusions
rules on one product can concern another product.
:type parent_combination: recordset `product.template.attribute.value`
:param parent_name: the name of the parent product combination.
:type parent_name: str
:return: dict of exclusions
- exclusions: from this product itself
- 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()),
'archived_combinations': list(set(tuple(product.product_template_attribute_value_ids.ids) for product in archived_products) - 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):
"""Get exclusions coming from the current template.
Dictionnary, each product template attribute value is a key, and for each of them
the value is an array with the other ptav that they exclude (empty if no exclusion).
"""
self.ensure_one()
product_template_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids
return {
ptav.id: [
value_id
for filter_line in ptav.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == self
) for value_id in filter_line.value_ids.ids
]
for ptav in product_template_attribute_values
}
def _get_parent_attribute_exclusions(self, parent_combination):
"""Get exclusions coming from the parent combination.
Dictionnary, each parent's ptav is a key, and for each of them the value is
an array with the other ptav that are excluded because of the parent.
"""
self.ensure_one()
if not parent_combination:
return {}
result = {}
for product_attribute_value in parent_combination:
for filter_line in product_attribute_value.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == self
):
# Some exclusions don't have attribute value. This means that the template is not
# compatible with the parent combination. If such an exclusion is found, it means that all
# attribute values are excluded.
if filter_line.value_ids:
result[product_attribute_value.id] = filter_line.value_ids.ids
else:
result[product_attribute_value.id] = filter_line.product_tmpl_id.mapped('attribute_line_ids.product_template_value_ids').ids
return result
def _get_mapped_attribute_names(self, parent_combination=None):
""" The name of every attribute values based on their id,
used to explain in the interface why that combination is not available
(e.g: Not available with Color: Black).
It contains both attribute value names from this product and from
the parent combination if provided.
"""
self.ensure_one()
all_product_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids
if parent_combination:
all_product_attribute_values |= parent_combination
return {
attribute_value.id: attribute_value.display_name
for attribute_value in all_product_attribute_values
}
def _is_combination_possible_by_config(self, combination, ignore_no_variant=False):
"""Return whether the given combination is possible according to the config of attributes on the template
:param combination: the combination to check for possibility
:type combination: recordset `product.template.attribute.value`
:param ignore_no_variant: whether no_variant attributes should be ignored
:type ignore_no_variant: bool
:return: wether the given combination is possible according to the config of attributes on the template
:rtype: bool
"""
self.ensure_one()
attribute_lines = self.valid_product_template_attribute_line_ids
if ignore_no_variant:
attribute_lines = attribute_lines._without_no_variant_attributes()
if len(combination) != len(attribute_lines):
# number of attribute values passed is different than the
# configuration of attributes on the template
return False
if attribute_lines != combination.attribute_line_id:
# combination has different attributes than the ones configured on the template
return False
if not (attribute_lines.product_template_value_ids._only_active() >= combination):
# combination has different values than the ones configured on the template
return False
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]
current_ptav = current_line_values[current_ptav_index]
# removing exclusions from current_ptav as we're removing it from partial_combination
if current_ptav_index >= 0:
for ptav_to_include_back in all_exclusions[current_ptav]:
current_exclusions[ptav_to_include_back] -= 1
partial_combination -= current_ptav
if current_ptav_index < len(current_line_values) - 1:
# go to next value of current line
value_index_per_line[line_index] += 1
current_line_values = product_template_attribute_values_per_line[line_index]
current_ptav_index = value_index_per_line[line_index]
current_ptav = current_line_values[current_ptav_index]
elif line_index != 0:
# reset current line, and then go to previous line
value_index_per_line[line_index] = - 1
line_index -= 1
continue
else:
# we're done if we must reset first line
break
# adding exclusions from current_ptav as we're incorporating it in partial_combination
for ptav_to_exclude in all_exclusions[current_ptav]:
current_exclusions[ptav_to_exclude] += 1
partial_combination += current_ptav
# test if included values excludes current value or if current value exclude included values
if current_exclusions[current_ptav] or \
any(intersection in partial_combination for intersection in all_exclusions[current_ptav]):
continue
if line_index == len(product_template_attribute_values_per_line) - 1:
# submit combination if we're on the last line
yield partial_combination
else:
# else we go to the next line
line_index += 1
def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
"""Generator returning combinations that are possible, following the
sequence of attributes and values.
See `_is_combination_possible` for what is a possible combination.
When encountering an impossible combination, try to change the value
of attributes by starting with the further regarding their sequences.
Ignore attributes that have no values.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:param necessary_values: values that must be in the returned combination
:type necessary_values: recordset of `product.template.attribute.value`
:return: the possible combinations
:rtype: generator of recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
return _("The product template is archived so no combination is possible.")
necessary_values = necessary_values or self.env['product.template.attribute.value']
necessary_attribute_lines = necessary_values.mapped('attribute_line_id')
attribute_lines = self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal not in necessary_attribute_lines)
if not attribute_lines and self._is_combination_possible(necessary_values, parent_combination):
yield necessary_values
product_template_attribute_values_per_line = [
ptal.product_template_value_ids._only_active()
for ptal in attribute_lines
]
for partial_combination in self._cartesian_product(product_template_attribute_values_per_line, parent_combination):
combination = partial_combination + necessary_values
if self._is_combination_possible(combination, parent_combination):
yield combination
return _("There are no remaining possible combination.")
def _get_closest_possible_combination(self, combination):
"""See `_get_closest_possible_combinations` (one iteration).
This method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
"""
return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
def _get_closest_possible_combinations(self, combination):
"""Generator returning the possible combinations that are the closest to
the given combination.
If the given combination is incomplete, try to complete it.
If the given combination is invalid, try to remove values from it before
completing it.
:param combination: the values to include if they are possible
:type combination: recordset `product.template.attribute.value`
:return: the possible combinations that are including as much
elements as possible from the given combination.
:rtype: generator of recordset of product.template.attribute.value
"""
while True:
res = self._get_possible_combinations(necessary_values=combination)
try:
# If there is at least one result for the given combination
# we consider that combination set, and we yield all the
# possible combinations for it.
yield(next(res))
for cur in res:
yield(cur)
return _("There are no remaining closest combination.")
except StopIteration:
# There are no results for the given combination, we try to
# progressively remove values from it.
if not combination:
return _("There are no possible combination.")
combination = combination[:-1]
def _get_current_company(self, **kwargs):
"""Get the most appropriate company for this product.
If the company is set on the product, directly return it. Otherwise,
fallback to a contextual company.
:param kwargs: kwargs forwarded to the fallback method.
:return: the most appropriate company for this product
:rtype: recordset of one `res.company`
"""
self.ensure_one()
return self.company_id or self._get_current_company_fallback(**kwargs)
def _get_current_company_fallback(self, **kwargs):
"""Fallback to get the most appropriate company for this product.
This should only be called from `_get_current_company` but is defined
separately to allow override.
The final fallback will be the current user's company.
:return: the fallback company for this product
:rtype: recordset of one `res.company`
"""
self.ensure_one()
return self.env.company
def _get_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.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)
"""
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):
self = self.with_context(
empty_list_help_document_name=_("product"),
)
return super(ProductTemplate, self).get_empty_list_help(help)
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Products'),
'template': '/product/static/xls/product_template.xls'
}]
def get_contextual_price(self, product=None):
return self._get_contextual_price(product=product)
def _get_contextual_price(self, product=None):
self.ensure_one()
# YTI TODO: During website_sale cleaning, we should get rid of those crappy context thing
pricelist = self._get_contextual_pricelist()
if not pricelist:
return 0.0
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.
"""
if self._context.get('pricelist'):
return self.env['product.pricelist'].browse(self._context.get('pricelist'))
return self.env['product.pricelist']