diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/product/models/product_attribute.py | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/product/models/product_attribute.py')
| -rw-r--r-- | addons/product/models/product_attribute.py | 573 |
1 files changed, 573 insertions, 0 deletions
diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py new file mode 100644 index 00000000..8c252c22 --- /dev/null +++ b/addons/product/models/product_attribute.py @@ -0,0 +1,573 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, tools, _ +from odoo.exceptions import UserError, ValidationError +from odoo.osv import expression + + +class ProductAttribute(models.Model): + _name = "product.attribute" + _description = "Product Attribute" + # if you change this _order, keep it in sync with the method + # `_sort_key_attribute_value` in `product.template` + _order = 'sequence, id' + + name = fields.Char('Attribute', required=True, translate=True) + value_ids = fields.One2many('product.attribute.value', 'attribute_id', 'Values', copy=True) + sequence = fields.Integer('Sequence', help="Determine the display order", index=True) + attribute_line_ids = fields.One2many('product.template.attribute.line', 'attribute_id', 'Lines') + create_variant = fields.Selection([ + ('always', 'Instantly'), + ('dynamic', 'Dynamically'), + ('no_variant', 'Never')], + default='always', + string="Variants Creation Mode", + help="""- Instantly: All possible variants are created as soon as the attribute and its values are added to a product. + - Dynamically: Each variant is created only when its corresponding attributes and values are added to a sales order. + - Never: Variants are never created for the attribute. + Note: the variants creation mode cannot be changed once the attribute is used on at least one product.""", + required=True) + is_used_on_products = fields.Boolean('Used on Products', compute='_compute_is_used_on_products') + product_tmpl_ids = fields.Many2many('product.template', string="Related Products", compute='_compute_products', store=True) + display_type = fields.Selection([ + ('radio', 'Radio'), + ('select', 'Select'), + ('color', 'Color')], default='radio', required=True, help="The display type used in the Product Configurator.") + + @api.depends('product_tmpl_ids') + def _compute_is_used_on_products(self): + for pa in self: + pa.is_used_on_products = bool(pa.product_tmpl_ids) + + @api.depends('attribute_line_ids.active', 'attribute_line_ids.product_tmpl_id') + def _compute_products(self): + for pa in self: + pa.product_tmpl_ids = pa.attribute_line_ids.product_tmpl_id + + def _without_no_variant_attributes(self): + return self.filtered(lambda pa: pa.create_variant != 'no_variant') + + def write(self, vals): + """Override to make sure attribute type can't be changed if it's used on + a product template. + + This is important to prevent because changing the type would make + existing combinations invalid without recomputing them, and recomputing + them might take too long and we don't want to change products without + the user knowing about it.""" + if 'create_variant' in vals: + for pa in self: + if vals['create_variant'] != pa.create_variant and pa.is_used_on_products: + raise UserError( + _("You cannot change the Variants Creation Mode of the attribute %s because it is used on the following products:\n%s") % + (pa.display_name, ", ".join(pa.product_tmpl_ids.mapped('display_name'))) + ) + invalidate_cache = 'sequence' in vals and any(record.sequence != vals['sequence'] for record in self) + res = super(ProductAttribute, self).write(vals) + if invalidate_cache: + # prefetched o2m have to be resequenced + # (eg. product.template: attribute_line_ids) + self.flush() + self.invalidate_cache() + return res + + def unlink(self): + for pa in self: + if pa.is_used_on_products: + raise UserError( + _("You cannot delete the attribute %s because it is used on the following products:\n%s") % + (pa.display_name, ", ".join(pa.product_tmpl_ids.mapped('display_name'))) + ) + return super(ProductAttribute, self).unlink() + + +class ProductAttributeValue(models.Model): + _name = "product.attribute.value" + # if you change this _order, keep it in sync with the method + # `_sort_key_variant` in `product.template' + _order = 'attribute_id, sequence, id' + _description = 'Attribute Value' + + name = fields.Char(string='Value', required=True, translate=True) + sequence = fields.Integer(string='Sequence', help="Determine the display order", index=True) + attribute_id = fields.Many2one('product.attribute', string="Attribute", ondelete='cascade', required=True, index=True, + help="The attribute cannot be changed once the value is used on at least one product.") + + pav_attribute_line_ids = fields.Many2many('product.template.attribute.line', string="Lines", + relation='product_attribute_value_product_template_attribute_line_rel', copy=False) + is_used_on_products = fields.Boolean('Used on Products', compute='_compute_is_used_on_products') + + is_custom = fields.Boolean('Is custom value', help="Allow users to input custom values for this attribute value") + html_color = fields.Char( + string='Color', + help="Here you can set a specific HTML color index (e.g. #ff0000) to display the color if the attribute type is 'Color'.") + display_type = fields.Selection(related='attribute_id.display_type', readonly=True) + + _sql_constraints = [ + ('value_company_uniq', 'unique (name, attribute_id)', "You cannot create two values with the same name for the same attribute.") + ] + + @api.depends('pav_attribute_line_ids') + def _compute_is_used_on_products(self): + for pav in self: + pav.is_used_on_products = bool(pav.pav_attribute_line_ids) + + def name_get(self): + """Override because in general the name of the value is confusing if it + is displayed without the name of the corresponding attribute. + Eg. on product list & kanban views, on BOM form view + + However during variant set up (on the product template form) the name of + the attribute is already on each line so there is no need to repeat it + on every value. + """ + if not self._context.get('show_attribute', True): + return super(ProductAttributeValue, self).name_get() + return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self] + + def write(self, values): + if 'attribute_id' in values: + for pav in self: + if pav.attribute_id.id != values['attribute_id'] and pav.is_used_on_products: + raise UserError( + _("You cannot change the attribute of the value %s because it is used on the following products:%s") % + (pav.display_name, ", ".join(pav.pav_attribute_line_ids.product_tmpl_id.mapped('display_name'))) + ) + + invalidate_cache = 'sequence' in values and any(record.sequence != values['sequence'] for record in self) + res = super(ProductAttributeValue, self).write(values) + if invalidate_cache: + # prefetched o2m have to be resequenced + # (eg. product.template.attribute.line: value_ids) + self.flush() + self.invalidate_cache() + return res + + def unlink(self): + for pav in self: + if pav.is_used_on_products: + raise UserError( + _("You cannot delete the value %s because it is used on the following products:\n%s") % + (pav.display_name, ", ".join(pav.pav_attribute_line_ids.product_tmpl_id.mapped('display_name'))) + ) + return super(ProductAttributeValue, self).unlink() + + def _without_no_variant_attributes(self): + return self.filtered(lambda pav: pav.attribute_id.create_variant != 'no_variant') + + +class ProductTemplateAttributeLine(models.Model): + """Attributes available on product.template with their selected values in a m2m. + Used as a configuration model to generate the appropriate product.template.attribute.value""" + + _name = "product.template.attribute.line" + _rec_name = 'attribute_id' + _description = 'Product Template Attribute Line' + _order = 'attribute_id, id' + + active = fields.Boolean(default=True) + product_tmpl_id = fields.Many2one('product.template', string="Product Template", ondelete='cascade', required=True, index=True) + attribute_id = fields.Many2one('product.attribute', string="Attribute", ondelete='restrict', required=True, index=True) + value_ids = fields.Many2many('product.attribute.value', string="Values", domain="[('attribute_id', '=', attribute_id)]", + relation='product_attribute_value_product_template_attribute_line_rel', ondelete='restrict') + product_template_value_ids = fields.One2many('product.template.attribute.value', 'attribute_line_id', string="Product Attribute Values") + + @api.onchange('attribute_id') + def _onchange_attribute_id(self): + self.value_ids = self.value_ids.filtered(lambda pav: pav.attribute_id == self.attribute_id) + + @api.constrains('active', 'value_ids', 'attribute_id') + def _check_valid_values(self): + for ptal in self: + if ptal.active and not ptal.value_ids: + raise ValidationError( + _("The attribute %s must have at least one value for the product %s.") % + (ptal.attribute_id.display_name, ptal.product_tmpl_id.display_name) + ) + for pav in ptal.value_ids: + if pav.attribute_id != ptal.attribute_id: + raise ValidationError( + _("On the product %s you cannot associate the value %s with the attribute %s because they do not match.") % + (ptal.product_tmpl_id.display_name, pav.display_name, ptal.attribute_id.display_name) + ) + return True + + @api.model_create_multi + def create(self, vals_list): + """Override to: + - Activate archived lines having the same configuration (if they exist) + instead of creating new lines. + - Set up related values and related variants. + + Reactivating existing lines allows to re-use existing variants when + possible, keeping their configuration and avoiding duplication. + """ + create_values = [] + activated_lines = self.env['product.template.attribute.line'] + for value in vals_list: + vals = dict(value, active=value.get('active', True)) + # While not ideal for peformance, this search has to be done at each + # step to exclude the lines that might have been activated at a + # previous step. Since `vals_list` will likely be a small list in + # all use cases, this is an acceptable trade-off. + archived_ptal = self.search([ + ('active', '=', False), + ('product_tmpl_id', '=', vals.pop('product_tmpl_id', 0)), + ('attribute_id', '=', vals.pop('attribute_id', 0)), + ], limit=1) + if archived_ptal: + # Write given `vals` in addition of `active` to ensure + # `value_ids` or other fields passed to `create` are saved too, + # but change the context to avoid updating the values and the + # variants until all the expected lines are created/updated. + archived_ptal.with_context(update_product_template_attribute_values=False).write(vals) + activated_lines += archived_ptal + else: + create_values.append(value) + res = activated_lines + super(ProductTemplateAttributeLine, self).create(create_values) + res._update_product_template_attribute_values() + return res + + def write(self, values): + """Override to: + - Add constraints to prevent doing changes that are not supported such + as modifying the template or the attribute of existing lines. + - Clean up related values and related variants when archiving or when + updating `value_ids`. + """ + if 'product_tmpl_id' in values: + for ptal in self: + if ptal.product_tmpl_id.id != values['product_tmpl_id']: + raise UserError( + _("You cannot move the attribute %s from the product %s to the product %s.") % + (ptal.attribute_id.display_name, ptal.product_tmpl_id.display_name, values['product_tmpl_id']) + ) + + if 'attribute_id' in values: + for ptal in self: + if ptal.attribute_id.id != values['attribute_id']: + raise UserError( + _("On the product %s you cannot transform the attribute %s into the attribute %s.") % + (ptal.product_tmpl_id.display_name, ptal.attribute_id.display_name, values['attribute_id']) + ) + # Remove all values while archiving to make sure the line is clean if it + # is ever activated again. + if not values.get('active', True): + values['value_ids'] = [(5, 0, 0)] + res = super(ProductTemplateAttributeLine, self).write(values) + if 'active' in values: + self.flush() + self.env['product.template'].invalidate_cache(fnames=['attribute_line_ids']) + # If coming from `create`, no need to update the values and the variants + # before all lines are created. + if self.env.context.get('update_product_template_attribute_values', True): + self._update_product_template_attribute_values() + return res + + def unlink(self): + """Override to: + - Archive the line if unlink is not possible. + - Clean up related values and related variants. + + Archiving is typically needed when the line has values that can't be + deleted because they are referenced elsewhere (on a variant that can't + be deleted, on a sales order line, ...). + """ + # Try to remove the values first to remove some potentially blocking + # references, which typically works: + # - For single value lines because the values are directly removed from + # the variants. + # - For values that are present on variants that can be deleted. + self.product_template_value_ids._only_active().unlink() + # Keep a reference to the related templates before the deletion. + templates = self.product_tmpl_id + # Now delete or archive the lines. + ptal_to_archive = self.env['product.template.attribute.line'] + for ptal in self: + try: + with self.env.cr.savepoint(), tools.mute_logger('odoo.sql_db'): + super(ProductTemplateAttributeLine, ptal).unlink() + except Exception: + # We catch all kind of exceptions to be sure that the operation + # doesn't fail. + ptal_to_archive += ptal + ptal_to_archive.write({'active': False}) + # For archived lines `_update_product_template_attribute_values` is + # implicitly called during the `write` above, but for products that used + # unlinked lines `_create_variant_ids` has to be called manually. + (templates - ptal_to_archive.product_tmpl_id)._create_variant_ids() + return True + + def _update_product_template_attribute_values(self): + """Create or unlink `product.template.attribute.value` for each line in + `self` based on `value_ids`. + + The goal is to delete all values that are not in `value_ids`, to + activate those in `value_ids` that are currently archived, and to create + those in `value_ids` that didn't exist. + + This is a trick for the form view and for performance in general, + because we don't want to generate in advance all possible values for all + templates, but only those that will be selected. + """ + ProductTemplateAttributeValue = self.env['product.template.attribute.value'] + ptav_to_create = [] + ptav_to_unlink = ProductTemplateAttributeValue + for ptal in self: + ptav_to_activate = ProductTemplateAttributeValue + remaining_pav = ptal.value_ids + for ptav in ptal.product_template_value_ids: + if ptav.product_attribute_value_id not in remaining_pav: + # Remove values that existed but don't exist anymore, but + # ignore those that are already archived because if they are + # archived it means they could not be deleted previously. + if ptav.ptav_active: + ptav_to_unlink += ptav + else: + # Activate corresponding values that are currently archived. + remaining_pav -= ptav.product_attribute_value_id + if not ptav.ptav_active: + ptav_to_activate += ptav + + for pav in remaining_pav: + # The previous loop searched for archived values that belonged to + # the current line, but if the line was deleted and another line + # was recreated for the same attribute, we need to expand the + # search to those with matching `attribute_id`. + # While not ideal for peformance, this search has to be done at + # each step to exclude the values that might have been activated + # at a previous step. Since `remaining_pav` will likely be a + # small list in all use cases, this is an acceptable trade-off. + ptav = ProductTemplateAttributeValue.search([ + ('ptav_active', '=', False), + ('product_tmpl_id', '=', ptal.product_tmpl_id.id), + ('attribute_id', '=', ptal.attribute_id.id), + ('product_attribute_value_id', '=', pav.id), + ], limit=1) + if ptav: + ptav.write({'ptav_active': True, 'attribute_line_id': ptal.id}) + # If the value was marked for deletion, now keep it. + ptav_to_unlink -= ptav + else: + # create values that didn't exist yet + ptav_to_create.append({ + 'product_attribute_value_id': pav.id, + 'attribute_line_id': ptal.id + }) + # Handle active at each step in case a following line might want to + # re-use a value that was archived at a previous step. + ptav_to_activate.write({'ptav_active': True}) + ptav_to_unlink.write({'ptav_active': False}) + ptav_to_unlink.unlink() + ProductTemplateAttributeValue.create(ptav_to_create) + self.product_tmpl_id._create_variant_ids() + + @api.model + def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): + # TDE FIXME: currently overriding the domain; however as it includes a + # search on a m2o and one on a m2m, probably this will quickly become + # difficult to compute - check if performance optimization is required + if name and operator in ('=', 'ilike', '=ilike', 'like', '=like'): + args = args or [] + domain = ['|', ('attribute_id', operator, name), ('value_ids', operator, name)] + return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid) + return super(ProductTemplateAttributeLine, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + + def _without_no_variant_attributes(self): + return self.filtered(lambda ptal: ptal.attribute_id.create_variant != 'no_variant') + + +class ProductTemplateAttributeValue(models.Model): + """Materialized relationship between attribute values + and product template generated by the product.template.attribute.line""" + + _name = "product.template.attribute.value" + _description = "Product Template Attribute Value" + _order = 'attribute_line_id, product_attribute_value_id, id' + + # Not just `active` because we always want to show the values except in + # specific case, as opposed to `active_test`. + ptav_active = fields.Boolean("Active", default=True) + name = fields.Char('Value', related="product_attribute_value_id.name") + + # defining fields: the product template attribute line and the product attribute value + product_attribute_value_id = fields.Many2one( + 'product.attribute.value', string='Attribute Value', + required=True, ondelete='cascade', index=True) + attribute_line_id = fields.Many2one('product.template.attribute.line', required=True, ondelete='cascade', index=True) + + # configuration fields: the price_extra and the exclusion rules + price_extra = fields.Float( + string="Value Price Extra", + default=0.0, + digits='Product Price', + help="Extra price for the variant with this attribute value on sale price. eg. 200 price extra, 1000 + 200 = 1200.") + currency_id = fields.Many2one(related='attribute_line_id.product_tmpl_id.currency_id') + + exclude_for = fields.One2many( + 'product.template.attribute.exclusion', + 'product_template_attribute_value_id', + string="Exclude for", + help="Make this attribute value not compatible with " + "other values of the product or some attribute values of optional and accessory products.") + + # related fields: product template and product attribute + product_tmpl_id = fields.Many2one('product.template', string="Product Template", related='attribute_line_id.product_tmpl_id', store=True, index=True) + attribute_id = fields.Many2one('product.attribute', string="Attribute", related='attribute_line_id.attribute_id', store=True, index=True) + ptav_product_variant_ids = fields.Many2many('product.product', relation='product_variant_combination', string="Related Variants", readonly=True) + + html_color = fields.Char('HTML Color Index', related="product_attribute_value_id.html_color") + is_custom = fields.Boolean('Is custom value', related="product_attribute_value_id.is_custom") + display_type = fields.Selection(related='product_attribute_value_id.display_type', readonly=True) + + _sql_constraints = [ + ('attribute_value_unique', 'unique(attribute_line_id, product_attribute_value_id)', "Each value should be defined only once per attribute per product."), + ] + + @api.constrains('attribute_line_id', 'product_attribute_value_id') + def _check_valid_values(self): + for ptav in self: + if ptav.product_attribute_value_id not in ptav.attribute_line_id.value_ids: + raise ValidationError( + _("The value %s is not defined for the attribute %s on the product %s.") % + (ptav.product_attribute_value_id.display_name, ptav.attribute_id.display_name, ptav.product_tmpl_id.display_name) + ) + + @api.model_create_multi + def create(self, vals_list): + if any('ptav_product_variant_ids' in v for v in vals_list): + # Force write on this relation from `product.product` to properly + # trigger `_compute_combination_indices`. + raise UserError(_("You cannot update related variants from the values. Please update related values from the variants.")) + return super(ProductTemplateAttributeValue, self).create(vals_list) + + def write(self, values): + if 'ptav_product_variant_ids' in values: + # Force write on this relation from `product.product` to properly + # trigger `_compute_combination_indices`. + raise UserError(_("You cannot update related variants from the values. Please update related values from the variants.")) + pav_in_values = 'product_attribute_value_id' in values + product_in_values = 'product_tmpl_id' in values + if pav_in_values or product_in_values: + for ptav in self: + if pav_in_values and ptav.product_attribute_value_id.id != values['product_attribute_value_id']: + raise UserError( + _("You cannot change the value of the value %s set on product %s.") % + (ptav.display_name, ptav.product_tmpl_id.display_name) + ) + if product_in_values and ptav.product_tmpl_id.id != values['product_tmpl_id']: + raise UserError( + _("You cannot change the product of the value %s set on product %s.") % + (ptav.display_name, ptav.product_tmpl_id.display_name) + ) + return super(ProductTemplateAttributeValue, self).write(values) + + def unlink(self): + """Override to: + - Clean up the variants that use any of the values in self: + - Remove the value from the variant if the value belonged to an + attribute line with only one value. + - Unlink or archive all related variants. + - Archive the value if unlink is not possible. + + Archiving is typically needed when the value is referenced elsewhere + (on a variant that can't be deleted, on a sales order line, ...). + """ + # Directly remove the values from the variants for lines that had single + # value (counting also the values that are archived). + single_values = self.filtered(lambda ptav: len(ptav.attribute_line_id.product_template_value_ids) == 1) + for ptav in single_values: + ptav.ptav_product_variant_ids.write({'product_template_attribute_value_ids': [(3, ptav.id, 0)]}) + # Try to remove the variants before deleting to potentially remove some + # blocking references. + self.ptav_product_variant_ids._unlink_or_archive() + # Now delete or archive the values. + ptav_to_archive = self.env['product.template.attribute.value'] + for ptav in self: + try: + with self.env.cr.savepoint(), tools.mute_logger('odoo.sql_db'): + super(ProductTemplateAttributeValue, ptav).unlink() + except Exception: + # We catch all kind of exceptions to be sure that the operation + # doesn't fail. + ptav_to_archive += ptav + ptav_to_archive.write({'ptav_active': False}) + return True + + def name_get(self): + """Override because in general the name of the value is confusing if it + is displayed without the name of the corresponding attribute. + Eg. on exclusion rules form + """ + return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self] + + def _only_active(self): + return self.filtered(lambda ptav: ptav.ptav_active) + + def _without_no_variant_attributes(self): + return self.filtered(lambda ptav: ptav.attribute_id.create_variant != 'no_variant') + + def _ids2str(self): + return ','.join([str(i) for i in sorted(self.ids)]) + + def _get_combination_name(self): + """Exclude values from single value lines or from no_variant attributes.""" + return ", ".join([ptav.name for ptav in self._without_no_variant_attributes()._filter_single_value_lines()]) + + def _filter_single_value_lines(self): + """Return `self` with values from single value lines filtered out + depending on the active state of all the values in `self`. + + If any value in `self` is archived, archived values are also taken into + account when checking for single values. + This allows to display the correct name for archived variants. + + If all values in `self` are active, only active values are taken into + account when checking for single values. + This allows to display the correct name for active combinations. + """ + only_active = all(ptav.ptav_active for ptav in self) + return self.filtered(lambda ptav: not ptav._is_from_single_value_line(only_active)) + + def _is_from_single_value_line(self, only_active=True): + """Return whether `self` is from a single value line, counting also + archived values if `only_active` is False. + """ + self.ensure_one() + all_values = self.attribute_line_id.product_template_value_ids + if only_active: + all_values = all_values._only_active() + return len(all_values) == 1 + + +class ProductTemplateAttributeExclusion(models.Model): + _name = "product.template.attribute.exclusion" + _description = 'Product Template Attribute Exclusion' + _order = 'product_tmpl_id, id' + + product_template_attribute_value_id = fields.Many2one( + 'product.template.attribute.value', string="Attribute Value", ondelete='cascade', index=True) + product_tmpl_id = fields.Many2one( + 'product.template', string='Product Template', ondelete='cascade', required=True, index=True) + value_ids = fields.Many2many( + 'product.template.attribute.value', relation="product_attr_exclusion_value_ids_rel", + string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id), ('ptav_active', '=', True)]") + + +class ProductAttributeCustomValue(models.Model): + _name = "product.attribute.custom.value" + _description = 'Product Attribute Custom Value' + _order = 'custom_product_template_attribute_value_id, id' + + name = fields.Char("Name", compute='_compute_name') + custom_product_template_attribute_value_id = fields.Many2one('product.template.attribute.value', string="Attribute Value", required=True, ondelete='restrict') + custom_value = fields.Char("Custom Value") + + @api.depends('custom_product_template_attribute_value_id.name', 'custom_value') + def _compute_name(self): + for record in self: + name = (record.custom_value or '').strip() + if record.custom_product_template_attribute_value_id.display_name: + name = "%s: %s" % (record.custom_product_template_attribute_value_id.display_name, name) + record.name = name |
