diff --git a/product_variant_change_attribute_value/README.rst b/product_variant_change_attribute_value/README.rst new file mode 100644 index 000000000..be2044d52 --- /dev/null +++ b/product_variant_change_attribute_value/README.rst @@ -0,0 +1,103 @@ +====================================== +Product Variant Change Attribute Value +====================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ecf4650ae8b762bab7dac08e47413e4e29369e8c94e0bec258dabd32f12ac45f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--variant-lightgray.png?logo=github + :target: https://github.com/OCA/product-variant/tree/18.0/product_variant_change_attribute_value + :alt: OCA/product-variant +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-variant-18-0/product-variant-18-0-product_variant_change_attribute_value + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/product-variant&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +In standard Odoo there is no way to change the attribute values assigned +to a product variant. + +This module adds a wizard to change or remove attribute values on all +product variant selected. + +When an attribute value is removed from all variants of a template, it +will also be removed from the configuration of the template. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The wizard can be access from the product variant tree view through the +context action menu Change attribute values assigned + +Known issues / Roadmap +====================== + +Rename to shorter name in v14. Eg: product_attribute_manager or similar. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp SA + +Contributors +------------ + +- Thierry Ducrest +- Khoi (Kien Kim) + +Other credits +------------- + +The migration of this module from 14.0 to 18.0 was financially supported +by: + +- Camptocamp + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/product-variant `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_variant_change_attribute_value/__init__.py b/product_variant_change_attribute_value/__init__.py new file mode 100644 index 000000000..5cb1c4914 --- /dev/null +++ b/product_variant_change_attribute_value/__init__.py @@ -0,0 +1 @@ +from . import wizards diff --git a/product_variant_change_attribute_value/__manifest__.py b/product_variant_change_attribute_value/__manifest__.py new file mode 100644 index 000000000..ea4c30978 --- /dev/null +++ b/product_variant_change_attribute_value/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +{ + "name": "Product Variant Change Attribute Value", + "version": "18.0.1.0.0", + "author": "Camptocamp SA, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-variant", + "license": "AGPL-3", + "category": "Product Variant", + "depends": ["product"], + "data": [ + "security/ir.model.access.csv", + "wizards/product_variant_attribute_value_wizard.xml", + ], + "demo": ["demo/product_demo.xml"], + "installable": True, +} diff --git a/product_variant_change_attribute_value/demo/product_demo.xml b/product_variant_change_attribute_value/demo/product_demo.xml new file mode 100644 index 000000000..79926333d --- /dev/null +++ b/product_variant_change_attribute_value/demo/product_demo.xml @@ -0,0 +1,38 @@ + + + + + Custom Desk + + 100.0 + 100.0 + consu + 1.0 + + + + + + + + + + + + + + + + diff --git a/product_variant_change_attribute_value/pyproject.toml b/product_variant_change_attribute_value/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/product_variant_change_attribute_value/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_variant_change_attribute_value/readme/CONTRIBUTORS.md b/product_variant_change_attribute_value/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..d19439c8f --- /dev/null +++ b/product_variant_change_attribute_value/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Thierry Ducrest \<\> +- Khoi (Kien Kim) \<\> diff --git a/product_variant_change_attribute_value/readme/CREDITS.md b/product_variant_change_attribute_value/readme/CREDITS.md new file mode 100644 index 000000000..efcd44035 --- /dev/null +++ b/product_variant_change_attribute_value/readme/CREDITS.md @@ -0,0 +1,3 @@ +The migration of this module from 14.0 to 18.0 was financially supported by: + +- Camptocamp diff --git a/product_variant_change_attribute_value/readme/DESCRIPTION.md b/product_variant_change_attribute_value/readme/DESCRIPTION.md new file mode 100644 index 000000000..022251812 --- /dev/null +++ b/product_variant_change_attribute_value/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +In standard Odoo there is no way to change the attribute values assigned +to a product variant. + +This module adds a wizard to change or remove attribute values on all +product variant selected. + +When an attribute value is removed from all variants of a template, it +will also be removed from the configuration of the template. diff --git a/product_variant_change_attribute_value/readme/ROADMAP.md b/product_variant_change_attribute_value/readme/ROADMAP.md new file mode 100644 index 000000000..7d7df56a4 --- /dev/null +++ b/product_variant_change_attribute_value/readme/ROADMAP.md @@ -0,0 +1 @@ +Rename to shorter name in v14. Eg: product_attribute_manager or similar. diff --git a/product_variant_change_attribute_value/readme/USAGE.md b/product_variant_change_attribute_value/readme/USAGE.md new file mode 100644 index 000000000..80311f852 --- /dev/null +++ b/product_variant_change_attribute_value/readme/USAGE.md @@ -0,0 +1,2 @@ +The wizard can be access from the product variant tree view through the +context action menu Change attribute values assigned diff --git a/product_variant_change_attribute_value/security/ir.model.access.csv b/product_variant_change_attribute_value/security/ir.model.access.csv new file mode 100644 index 000000000..83d69883d --- /dev/null +++ b/product_variant_change_attribute_value/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_variant_attribute_value_action_employee,variant.attribute.value.action employee,model_variant_attribute_value_action,base.group_user,1,0,0,0 +access_variant_attribute_value_action_manager,variant.attribute.value.action manager,model_variant_attribute_value_action,base.group_system,1,1,1,1 +access_variant_attribute_value_wizard_employee,variant.attribute.value.wizard employee,model_variant_attribute_value_wizard,base.group_user,1,0,0,0 +access_variant_attribute_value_wizard_manager,variant.attribute.value.wizard manager,model_variant_attribute_value_wizard,base.group_system,1,1,1,1 diff --git a/product_variant_change_attribute_value/static/description/index.html b/product_variant_change_attribute_value/static/description/index.html new file mode 100644 index 000000000..d22a97497 --- /dev/null +++ b/product_variant_change_attribute_value/static/description/index.html @@ -0,0 +1,449 @@ + + + + + +Product Variant Change Attribute Value + + + +
+

Product Variant Change Attribute Value

+ + +

Beta License: AGPL-3 OCA/product-variant Translate me on Weblate Try me on Runboat

+

In standard Odoo there is no way to change the attribute values assigned +to a product variant.

+

This module adds a wizard to change or remove attribute values on all +product variant selected.

+

When an attribute value is removed from all variants of a template, it +will also be removed from the configuration of the template.

+

Table of contents

+ +
+

Usage

+

The wizard can be access from the product variant tree view through the +context action menu Change attribute values assigned

+
+
+

Known issues / Roadmap

+

Rename to shorter name in v14. Eg: product_attribute_manager or similar.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp SA
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 14.0 to 18.0 was financially supported +by:

+
    +
  • Camptocamp
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/product-variant project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/product_variant_change_attribute_value/tests/__init__.py b/product_variant_change_attribute_value/tests/__init__.py new file mode 100644 index 000000000..abb17f96e --- /dev/null +++ b/product_variant_change_attribute_value/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_variant_change_attribute_value diff --git a/product_variant_change_attribute_value/tests/test_product_variant_change_attribute_value.py b/product_variant_change_attribute_value/tests/test_product_variant_change_attribute_value.py new file mode 100644 index 000000000..f19e346be --- /dev/null +++ b/product_variant_change_attribute_value/tests/test_product_variant_change_attribute_value.py @@ -0,0 +1,311 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +from odoo.exceptions import UserError +from odoo.tests import Form, tagged +from odoo.tools import mute_logger + +from odoo.addons.base.tests.common import BaseCommon + + +@tagged("post_install", "-at_install") +class TestProductVariantChangeAttributeValue(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.legs = cls.env.ref("product.product_attribute_1") + cls.steel = cls.env.ref("product.product_attribute_value_1") + cls.aluminium = cls.env.ref("product.product_attribute_value_2") + + cls.color = cls.env.ref("product.product_attribute_2") + cls.white = cls.env.ref("product.product_attribute_value_3") + cls.black = cls.env.ref("product.product_attribute_value_4") + cls.pink = cls.env["product.attribute.value"].create( + {"name": "Pink", "attribute_id": cls.color.id} + ) + cls.blue = cls.env["product.attribute.value"].create( + {"name": "Blue", "attribute_id": cls.color.id} + ) + cls.template = cls.env.ref( + "product_variant_change_attribute_value.product_product_1_product_template" + ) + cls.variants = cls.template.product_variant_ids + cls.variant_1 = cls.variants[0] + cls.variant_2 = cls.variants[1] + cls.variant_3 = cls.variants[2] + cls.variant_4 = cls.variants[3] + cls.used_values = ( + cls.variants.product_template_attribute_value_ids.product_attribute_value_id + ) + cls.wiz_model = cls.env["variant.attribute.value.wizard"] + + def _get_wiz(self, prod_ids=None): + prod_ids = prod_ids or self.variants.ids + context = {"active_model": "product.product", "active_ids": prod_ids} + return Form(self.wiz_model.with_context(**context)).save() + + def _change_action(self, wiz, value, attribute_action, replaced_by_id=False): + """Set an action to do by the wizard on an attribute value.""" + actions = wiz.attributes_action_ids + action = actions.filtered(lambda r: r.product_attribute_value_id == value) + action.attribute_action = attribute_action + action.replaced_by_id = replaced_by_id + + def _is_value_on_variant(self, variant, attribute_value): + values = variant.product_template_attribute_value_ids.mapped( + "product_attribute_value_id" + ) + return attribute_value in values + + def _is_attribute_value_on_template(self, product, attribute_value): + """Check if an attribute value is assigned to a variant template.""" + template = product.product_tmpl_id + attribute = attribute_value.attribute_id + attribute_line = template.attribute_line_ids.filtered( + lambda x: x.attribute_id == attribute + ) + if not attribute_line: + return False + ptav = self.env["product.template.attribute.value"].search( + [ + ("attribute_line_id", "=", attribute_line.id), + ("product_attribute_value_id", "=", attribute_value.id), + ], + limit=1, + ) + if not ptav: + return False + # Check that it is also active + return ptav.ptav_active + + def test_fields(self): + wiz = self._get_wiz() + self.assertEqual(wiz.product_ids, self.variants) + self.assertEqual(wiz.product_variant_count, len(self.variants)) + self.assertEqual(wiz.product_template_count, len(self.variants.product_tmpl_id)) + values = self.steel | self.aluminium | self.white | self.black + self.assertEqual(wiz.attribute_value_ids, values) + self.assertEqual(wiz.available_attribute_ids, self.legs | self.color) + self.assertEqual(len(wiz.attributes_action_ids), len(values)) + + def test_actions_field_filter(self): + wiz = self._get_wiz() + self.assertEqual(len(wiz.attributes_action_ids), len(wiz.attribute_value_ids)) + with Form(wiz) as res: + res.filter_attribute_id = self.legs + self.assertEqual( + len(res.attributes_action_ids), + len([x for x in self.legs.value_ids if x in self.used_values]), + ) + + @mute_logger("odoo.models.unlink") + def test_remove_attribute_value(self): + """Check removing an attribute value on ALL variants of a template.""" + self.assertTrue(self._is_value_on_variant(self.variant_1, self.steel)) + + wiz = self._get_wiz() + self._change_action(wiz, self.steel, "delete") + wiz.action_apply() + + self.assertFalse(self._is_value_on_variant(self.variant_1, self.steel)) + self.assertFalse( + self._is_attribute_value_on_template(self.variant_1, self.steel) + ) + + @mute_logger("odoo.models.unlink") + def test_remove_all_attribute_values(self): + """Check removing an attribute value on ALL variants of a template. + + Normally this can cause an error because you cannot delete all values + if the variants left do not have a unique combination of attributes. + """ + self.assertTrue(self._is_value_on_variant(self.variant_1, self.steel)) + + wiz = self._get_wiz() + self._change_action(wiz, self.steel, "delete") + self._change_action(wiz, self.aluminium, "delete") + wiz.action_apply() + self.assertFalse(self._is_value_on_variant(self.variant_1, self.steel)) + self.assertFalse( + self._is_attribute_value_on_template(self.variant_1, self.steel) + ) + + def test_remove_attribute_values_when_both_products_are_associated(self): + if "sale.order" not in self.env: + return + + self.partner = self.env["res.partner"].create( + { + "name": "Test Partner", + } + ) + self.sale_order_1 = self.env["sale.order"].create( + {"partner_id": self.partner.id} + ) + self.sale_order_line_1 = self.env["sale.order.line"].create( + { + "order_id": self.sale_order_1.id, + "name": self.variant_1.name, + "product_id": self.variant_1.id, + "product_uom_qty": 2, + "price_unit": 10, + "customer_lead": 1.0, + } + ) + self.sale_order_line_2 = self.env["sale.order.line"].create( + { + "order_id": self.sale_order_1.id, + "name": self.variant_3.name, + "product_id": self.variant_3.id, + "product_uom_qty": 2, + "price_unit": 10, + "customer_lead": 1.0, + } + ) + wiz = self._get_wiz() + self._change_action(wiz, self.steel, "delete") + self._change_action(wiz, self.aluminium, "delete") + with self.assertRaises(UserError) as err: + wiz.action_apply() + self.assertTrue( + err.exception.args[0].endswith( + "with Sale Orders/Invoices/etc., impossible to remove" + ) + ) + + def test_remove_all_attribute_values_when_one_product_is_associated(self): + if "sale.order" not in self.env: + return + + self.partner = self.env["res.partner"].create( + { + "name": "Test Partner", + } + ) + self.sale_order_1 = self.env["sale.order"].create( + {"partner_id": self.partner.id} + ) + self.sale_order_line_1 = self.env["sale.order.line"].create( + { + "order_id": self.sale_order_1.id, + "name": self.variant_1.name, + "product_id": self.variant_1.id, + "product_uom_qty": 2, + "price_unit": 10, + "customer_lead": 1.0, + } + ) + + wiz = self._get_wiz() + self._change_action(wiz, self.steel, "delete") + self._change_action(wiz, self.aluminium, "delete") + wiz.action_apply() + self.assertFalse(self._is_value_on_variant(self.variant_1, self.steel)) + + @mute_logger("odoo.models.unlink") + def test_change_attribute_value(self): + """Check changing an attribute value on ALL variant of a template.""" + self.assertTrue(self._is_value_on_variant(self.variant_1, self.white)) + + wiz = self._get_wiz() + self._change_action(wiz, self.white, "replace", self.pink) + wiz.action_apply() + + self.assertFalse(self._is_value_on_variant(self.variant_1, self.white)) + self.assertTrue(self._is_value_on_variant(self.variant_1, self.pink)) + # White has been removed from the template + self.assertFalse( + self._is_attribute_value_on_template(self.variant_1, self.white) + ) + + def test_change_attribute_value_2(self): + """Check changing an attribute value on some variant of a template. + + Changing the value white to pink on variant 3 and 4. + """ + self.assertTrue(self._is_value_on_variant(self.variant_3, self.white)) + self.assertFalse(self._is_value_on_variant(self.variant_4, self.white)) + # Variant 1 has the white attribute but is is not picked by the wizard + self.assertTrue(self._is_value_on_variant(self.variant_1, self.white)) + + wiz = self._get_wiz([self.variant_3.id, self.variant_4.id]) + self._change_action(wiz, self.white, "replace", self.pink) + wiz.action_apply() + + self.assertFalse(self._is_value_on_variant(self.variant_3, self.white)) + self.assertFalse(self._is_value_on_variant(self.variant_4, self.white)) + self.assertTrue(self._is_value_on_variant(self.variant_3, self.pink)) + self.assertFalse(self._is_value_on_variant(self.variant_4, self.pink)) + # The value should not be remove from the template because of variant 1 + self.assertTrue( + self._is_attribute_value_on_template(self.variant_1, self.white) + ) + self.assertTrue(self._is_value_on_variant(self.variant_1, self.white)) + + @mute_logger("odoo.models.unlink") + def test_active_deactivate_attribute_value_2_step(self): + """Deactivate a pav and reactivate it in 2 steps. + + Use the wizard to deactivate (not used anymore) the white attribute + And reactivate it by using it on another variant. + + """ + self.assertTrue( + self._is_attribute_value_on_template(self.variant_1, self.white) + ) + self.assertTrue( + self._is_attribute_value_on_template(self.variant_1, self.black) + ) + wiz = self._get_wiz() + self._change_action(wiz, self.white, "replace", self.pink) + wiz.action_apply() + self.assertFalse( + self._is_attribute_value_on_template(self.variant_1, self.white) + ) + self._change_action(wiz, self.black, "replace", self.white) + wiz.action_apply() + self.assertTrue( + self._is_attribute_value_on_template(self.variant_1, self.white) + ) + self.assertFalse( + self._is_attribute_value_on_template(self.variant_1, self.black) + ) + + @mute_logger("odoo.models.unlink") + def test_active_deactivate_attribute_value_1_step(self): + """Deactivate a pav and reactivate it in 1 steps. + + Same than previous tests but both replacement are done in one + execution of the wizard. + """ + self.assertEqual( + sorted(self.variants.mapped("display_name")), + sorted( + [ + "Custom Desk (Steel, White)", + "Custom Desk (Steel, Black)", + "Custom Desk (Aluminium, White)", + "Custom Desk (Aluminium, Black)", + ] + ), + ) + wiz = self._get_wiz() + self._change_action(wiz, self.white, "replace", self.pink) + self._change_action(wiz, self.black, "replace", self.white) + wiz.action_apply() + self.assertTrue( + self._is_attribute_value_on_template(self.variant_1, self.white) + ) + self.assertFalse( + self._is_attribute_value_on_template(self.variant_1, self.black) + ) + self.assertEqual( + sorted(self.variants.mapped("display_name")), + sorted( + [ + "Custom Desk (Steel, Pink)", + "Custom Desk (Steel, White)", + "Custom Desk (Aluminium, Pink)", + "Custom Desk (Aluminium, White)", + ] + ), + ) diff --git a/product_variant_change_attribute_value/wizards/__init__.py b/product_variant_change_attribute_value/wizards/__init__.py new file mode 100644 index 000000000..5b221e5ce --- /dev/null +++ b/product_variant_change_attribute_value/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import product_variant_attribute_value_action +from . import product_variant_attribute_value_wizard diff --git a/product_variant_change_attribute_value/wizards/product_variant_attribute_value_action.py b/product_variant_change_attribute_value/wizards/product_variant_attribute_value_action.py new file mode 100644 index 000000000..a9ccba20d --- /dev/null +++ b/product_variant_change_attribute_value/wizards/product_variant_attribute_value_action.py @@ -0,0 +1,68 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import api, fields, models + + +class ProductVariantAttributeValueAction(models.TransientModel): + _name = "variant.attribute.value.action" + _description = "Wizard action to do on variant attribute value" + _order = "attribute_id" + + product_attribute_value_id = fields.Many2one( + comodel_name="product.attribute.value", ondelete="cascade" + ) + attribute_action = fields.Selection( + selection="_selection_action", + default="do_nothing", + required=True, + ) + attribute_id = fields.Many2one( + comodel_name="product.attribute", + related="product_attribute_value_id.attribute_id", + store=True, + ondelete="cascade", + ) + selectable_attribute_value_ids = fields.Many2many( + comodel_name="product.attribute.value", + compute="_compute_selectable_attribute_value_ids", + ) + replaced_by_id = fields.Many2one( + comodel_name="product.attribute.value", + string="Replace with", + domain="[('id', 'in', selectable_attribute_value_ids)]", + ondelete="cascade", + ) + + def _selection_action(self): + return [ + ("do_nothing", "Do Nothing"), + ("replace", "Replace"), + ("delete", "Delete"), + ] + + @api.depends("attribute_action") + def _compute_selectable_attribute_value_ids(self): + # Use SQL because loading all `value_ids` from each related attribute + # is veeery slow. We don't care about permission at this point. + query = """ + SELECT + attribute_id,array_agg(id) + FROM + product_attribute_value + WHERE + attribute_id IN %(ids)s + GROUP BY + attribute_id + """ + ids = tuple(self.mapped("attribute_id").ids) + if not ids: + self.update({"selectable_attribute_value_ids": False}) + return + self.env["product.attribute.value"].flush_model(["attribute_id"]) + self.env.cr.execute(query, dict(ids=ids)) + values_by_attr = dict(self.env.cr.fetchall()) + for rec in self: + rec.selectable_attribute_value_ids = [ + (6, 0, values_by_attr.get(rec.attribute_id.id, [])) + ] diff --git a/product_variant_change_attribute_value/wizards/product_variant_attribute_value_wizard.py b/product_variant_change_attribute_value/wizards/product_variant_attribute_value_wizard.py new file mode 100644 index 000000000..352eb46d3 --- /dev/null +++ b/product_variant_change_attribute_value/wizards/product_variant_attribute_value_wizard.py @@ -0,0 +1,254 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from collections import defaultdict + +import psycopg2 + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class VariantAttributeValueWizard(models.TransientModel): + _name = "variant.attribute.value.wizard" + _description = "Wizard to change attributes on product variants" + + product_ids = fields.Many2many(comodel_name="product.product") + product_variant_count = fields.Integer(readonly=True) + product_template_count = fields.Integer(readonly=True) + attributes_action_ids = fields.Many2many( + comodel_name="variant.attribute.value.action", + relation="variant_attribute_wizard_attribute_action_rel", + ) + attribute_value_ids = fields.Many2many(comodel_name="product.attribute.value") + available_attribute_ids = fields.Many2many(comodel_name="product.attribute") + filter_attribute_id = fields.Many2one( + comodel_name="product.attribute", + domain="[('id', 'in', available_attribute_ids)]", + ) + + @api.model + def _get_actions_from_values(self, values, _filter=None): + if _filter: + values = values.filtered(lambda x: x.attribute_id == _filter) + return self.env["variant.attribute.value.action"].create( + [ + { + "product_attribute_value_id": x.id, + "attribute_id": x.attribute_id.id, + "attribute_action": "do_nothing", + } + for x in values._origin + ] + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_model = self.env.context.get("active_model") + if active_model != "product.product": + return res + active_ids = self.env.context.get("active_ids") + variants = self.env[active_model].browse(active_ids) + attribute_values = ( + variants.product_template_attribute_value_ids.product_attribute_value_id + ) + available_attributes = attribute_values.mapped("attribute_id") + actions = self._get_actions_from_values(attribute_values) + res.update( + { + "product_ids": [(6, 0, variants.ids)], + "attribute_value_ids": [(6, 0, attribute_values.ids)], + "available_attribute_ids": [(6, 0, available_attributes.ids)], + "attributes_action_ids": [(6, 0, actions.ids)], + "product_variant_count": len(variants), + "product_template_count": len(variants.mapped("product_tmpl_id")), + } + ) + return res + + @api.onchange("filter_attribute_id") + def _compute_attributes_action_ids(self): + """Update actions according to the attribute to filter on.""" + for rec in self: + actions = self._get_actions_from_values( + rec.attribute_value_ids, _filter=rec.filter_attribute_id + ) + rec.attributes_action_ids = [(6, 0, actions.ids)] + + def action_apply(self): + for product in self.product_ids: + self._action_apply(product) + + def _is_attribute_value_being_used(self, variant_id, attribute_value): + """Check if attribute value is still used by a variant.""" + existing_variants = self.env["product.product"].search( + [ + ("id", "!=", variant_id.id), + ("product_tmpl_id", "=", variant_id.product_tmpl_id.id), + ], + ) + existing_attributes = existing_variants.mapped( + "product_template_attribute_value_ids.product_attribute_value_id" + ) + return attribute_value in existing_attributes + + def _action_apply(self, product): + """Update a variant with all the actions set by the user in the wizard.""" + product_tmpl_av_ids = product.product_template_attribute_value_ids + pav_ids = product_tmpl_av_ids.mapped("product_attribute_value_id") + pavs_to_clean_by_attr = defaultdict(self.env["product.attribute.value"].browse) + for value_action in self.attributes_action_ids: + action = value_action.attribute_action + if action == "do_nothing": + continue + pav = value_action.product_attribute_value_id + if pav not in pav_ids: + continue + ptav_ids = product.product_template_attribute_value_ids.filtered( + lambda r, pav=pav: r.product_attribute_value_id != pav + ) + if action == "delete": + if pav.id in product_tmpl_av_ids.attribute_id.ids: + if self._remove_duplicate_product(product): + continue + # nothing to do because `_cleanup_attribute_value` will take care + elif action == "replace": + if not value_action.replaced_by_id: + continue + tpl_attr_value = self._handle_replace( + product, value_action.replaced_by_id + ) + ptav_ids |= tpl_attr_value + + # Update the values set on the product variant + product.product_template_attribute_value_ids = ptav_ids + # Remove the changed value from the template attribute line if needed + if not self._is_attribute_value_being_used(product, pav): + pavs_to_clean_by_attr[pav.attribute_id] |= pav + if pavs_to_clean_by_attr: + self._cleanup_attribute_values(product, pavs_to_clean_by_attr) + + def _handle_replace(self, product, pav_replacement): + TplAttrLine = self.env["product.template.attribute.line"] + TplAttrValue = self.env["product.template.attribute.value"] + template = product.product_tmpl_id + # Find corresponding attribute line on template or create it + attr = pav_replacement.attribute_id + tpl_attr_line = template.attribute_line_ids.filtered( + lambda x: x.attribute_id == attr + ) + if not tpl_attr_line: + tpl_attr_line = TplAttrLine.create( + { + "product_tmpl_id": template.id, + "attribute_id": attr.id, + "value_ids": [(6, False, [pav_replacement.id])], + } + ) + # Ensure the value exists in this attribute line. + # The context key 'update_product_template_attribute_values' avoids + # to create/unlink variants when values are updated on the template + # attribute line. + tpl_attr_line.with_context( + update_product_template_attribute_values=False + ).write({"value_ids": [(4, pav_replacement.id)]}) + # Get (or create if needed) the 'product.template.attribute.value' + tpl_attr_value = TplAttrValue.search( + [ + ("attribute_line_id", "=", tpl_attr_line.id), + ("product_attribute_value_id", "=", pav_replacement.id), + ] + ) + if not tpl_attr_value: + tpl_attr_value = TplAttrValue.create( + { + "attribute_line_id": tpl_attr_line.id, + "product_attribute_value_id": pav_replacement.id, + } + ) + return tpl_attr_value + + def _handle_unique_violation(self, func, error_msg): + try: + with self.env.cr.savepoint(): + func() + except psycopg2.IntegrityError as e: + if e.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise UserError(error_msg) from e + else: + raise + + def _cleanup_attribute_values(self, product, pavs_to_clean): + TplAttrValue = self.env["product.template.attribute.value"] + template = product.product_tmpl_id + for attr, pavs in pavs_to_clean.items(): + tpl_attr_line = template.attribute_line_ids.filtered( + lambda x, attr=attr: x.attribute_id == attr + ) + # Ensure that product variant combinations are not created + # during cleanup. + tpl_attr_line = tpl_attr_line.with_context( + update_product_template_attribute_values=False + ) + error_msg = self._unique_err_msg(product, tpl_attr_line, pavs) + if not set(tpl_attr_line.value_ids.ids) - set(pavs.ids): + # no value left + def _make_inactive(tpl_attr_line): + tpl_attr_line.active = False + + self._handle_unique_violation(_make_inactive(tpl_attr_line), error_msg) + tpl_attr_line.write({"value_ids": [(3, pav.id) for pav in pavs]}) + tpl_attr_values = TplAttrValue.search( + [ + ("attribute_line_id", "=", tpl_attr_line.id), + ("product_attribute_value_id", "in", pavs.ids), + ] + ) + if tpl_attr_values: + self._handle_unique_violation(tpl_attr_values.unlink, error_msg) + + def _remove_duplicate_product(self, product): + product_pavs = set(product.product_template_attribute_value_ids.ids) + for check_product in self.product_ids - product: + variant_pavs = set(check_product.product_template_attribute_value_ids.ids) + if not variant_pavs.issubset(product_pavs): + continue + if not self._is_product_associated(product): + product.unlink() + return True + elif not self._is_product_associated(check_product): + check_product.unlink() + else: + message = self.env._( + "Both products %s are associated with" + " Sale Orders/Invoices/etc., impossible to remove" + ) + names = [product.display_name, check_product.display_name] + raise UserError(message % (", ".join(names))) + return False + + def _is_product_associated(self, product): + line_models = [ + "account.move.line", + "purchase.order.line", + "sale.order.line", + "stock.move.line", + "stock.inventory.line", + "sale.order.template.line", + ] + models = self.env["ir.model"].search([("model", "in", line_models)]) + domain = [("product_id", "=", product.id)] + for model in models: + if self.env[model.model].search(domain, limit=1): + return True + return False + + def _unique_err_msg(self, product, tpl_attr_line, pavs): + msg = self.env._( + "Product '%(product_name)s' uniqueness compromised.\n " + "Impossible to remove value(s): %(values)s", + product_name=product.display_name, + values=", ".join(pavs.mapped("name")), + ) + return msg diff --git a/product_variant_change_attribute_value/wizards/product_variant_attribute_value_wizard.xml b/product_variant_change_attribute_value/wizards/product_variant_attribute_value_wizard.xml new file mode 100644 index 000000000..250b4aa70 --- /dev/null +++ b/product_variant_change_attribute_value/wizards/product_variant_attribute_value_wizard.xml @@ -0,0 +1,93 @@ + + + + Product Variant Update Attribute + variant.attribute.value.wizard + +
+ +
Updating variant(s) selected out of product template(s).
+ + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + Change attribute values assigned + ir.actions.act_window + variant.attribute.value.wizard + + list + form + new + {'default_product_ids': active_ids} + +