270 lines
13 KiB
Python
270 lines
13 KiB
Python
|
# 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.fields import Command
|
||
|
|
||
|
|
||
|
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'
|
||
|
_rec_names_search = ['attribute_id', 'value_ids']
|
||
|
_description = "Product Template Attribute Line"
|
||
|
_order = 'sequence, attribute_id, id'
|
||
|
|
||
|
active = fields.Boolean(default=True)
|
||
|
product_tmpl_id = fields.Many2one(
|
||
|
comodel_name='product.template',
|
||
|
string="Product Template",
|
||
|
ondelete='cascade',
|
||
|
required=True,
|
||
|
index=True)
|
||
|
sequence = fields.Integer("Sequence", default=10)
|
||
|
attribute_id = fields.Many2one(
|
||
|
comodel_name='product.attribute',
|
||
|
string="Attribute",
|
||
|
ondelete='restrict',
|
||
|
required=True,
|
||
|
index=True)
|
||
|
value_ids = fields.Many2many(
|
||
|
comodel_name='product.attribute.value',
|
||
|
relation='product_attribute_value_product_template_attribute_line_rel',
|
||
|
string="Values",
|
||
|
domain="[('attribute_id', '=', attribute_id)]",
|
||
|
ondelete='restrict')
|
||
|
value_count = fields.Integer(compute='_compute_value_count', store=True)
|
||
|
product_template_value_ids = fields.One2many(
|
||
|
comodel_name='product.template.attribute.value',
|
||
|
inverse_name='attribute_line_id',
|
||
|
string="Product Attribute Values")
|
||
|
|
||
|
@api.depends('value_ids')
|
||
|
def _compute_value_count(self):
|
||
|
for record in self:
|
||
|
record.value_count = len(record.value_ids)
|
||
|
|
||
|
@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 %(attribute)s must have at least one value for the product %(product)s.",
|
||
|
attribute=ptal.attribute_id.display_name,
|
||
|
product=ptal.product_tmpl_id.display_name,
|
||
|
))
|
||
|
for pav in ptal.value_ids:
|
||
|
if pav.attribute_id != ptal.attribute_id:
|
||
|
raise ValidationError(_(
|
||
|
"On the product %(product)s you cannot associate the value %(value)s"
|
||
|
" with the attribute %(attribute)s because they do not match.",
|
||
|
product=ptal.product_tmpl_id.display_name,
|
||
|
value=pav.display_name,
|
||
|
attribute=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().create(create_values)
|
||
|
if self._context.get("create_product_product", True):
|
||
|
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 %(attribute)s from the product"
|
||
|
" %(product_src)s to the product %(product_dest)s.",
|
||
|
attribute=ptal.attribute_id.display_name,
|
||
|
product_src=ptal.product_tmpl_id.display_name,
|
||
|
product_dest=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 %(product)s you cannot transform the attribute"
|
||
|
" %(attribute_src)s into the attribute %(attribute_dest)s.",
|
||
|
product=ptal.product_tmpl_id.display_name,
|
||
|
attribute_src=ptal.attribute_id.display_name,
|
||
|
attribute_dest=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'] = [Command.clear()]
|
||
|
res = super().write(values)
|
||
|
if 'active' in values:
|
||
|
self.env.flush_all()
|
||
|
self.env['product.template'].invalidate_model(['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.action_archive() # only calls write if there are records
|
||
|
# 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,
|
||
|
'price_extra': pav.default_extra_price,
|
||
|
})
|
||
|
# 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})
|
||
|
if ptav_to_unlink:
|
||
|
ptav_to_unlink.unlink()
|
||
|
ProductTemplateAttributeValue.create(ptav_to_create)
|
||
|
self.product_tmpl_id._create_variant_ids()
|
||
|
|
||
|
def _without_no_variant_attributes(self):
|
||
|
return self.filtered(lambda ptal: ptal.attribute_id.create_variant != 'no_variant')
|
||
|
|
||
|
def action_open_attribute_values(self):
|
||
|
return {
|
||
|
'type': 'ir.actions.act_window',
|
||
|
'name': _("Product Variant Values"),
|
||
|
'res_model': 'product.template.attribute.value',
|
||
|
'view_mode': 'tree,form',
|
||
|
'domain': [('id', 'in', self.product_template_value_ids.ids)],
|
||
|
'views': [
|
||
|
(self.env.ref('product.product_template_attribute_value_view_tree').id, 'list'),
|
||
|
(self.env.ref('product.product_template_attribute_value_view_form').id, 'form'),
|
||
|
],
|
||
|
'context': {
|
||
|
'search_default_active': 1,
|
||
|
},
|
||
|
}
|