From ef484ff14fd337fdd9065e20a7f3335892ecaf84 Mon Sep 17 00:00:00 2001 From: Ricardoalso Date: Thu, 23 Apr 2026 12:06:14 +0200 Subject: [PATCH] [ADD] product_class: classes to group product attributes This module introduces Product Classes to group product attributes and standardize product setup. --- product_class/README.rst | 95 ++++ product_class/__init__.py | 1 + product_class/__manifest__.py | 18 + product_class/models/__init__.py | 3 + product_class/models/product_attribute.py | 32 ++ product_class/models/product_class.py | 48 ++ product_class/models/product_template.py | 45 ++ product_class/pyproject.toml | 3 + product_class/readme/DESCRIPTION.md | 5 + product_class/security/ir.model.access.csv | 3 + product_class/static/description/index.html | 431 ++++++++++++++++++ product_class/tests/__init__.py | 1 + product_class/tests/test_product_class.py | 227 +++++++++ .../views/product_attribute_views.xml | 23 + product_class/views/product_class_views.xml | 64 +++ .../views/product_template_views.xml | 25 + 16 files changed, 1024 insertions(+) create mode 100644 product_class/README.rst create mode 100644 product_class/__init__.py create mode 100644 product_class/__manifest__.py create mode 100644 product_class/models/__init__.py create mode 100644 product_class/models/product_attribute.py create mode 100644 product_class/models/product_class.py create mode 100644 product_class/models/product_template.py create mode 100644 product_class/pyproject.toml create mode 100644 product_class/readme/DESCRIPTION.md create mode 100644 product_class/security/ir.model.access.csv create mode 100644 product_class/static/description/index.html create mode 100644 product_class/tests/__init__.py create mode 100644 product_class/tests/test_product_class.py create mode 100644 product_class/views/product_attribute_views.xml create mode 100644 product_class/views/product_class_views.xml create mode 100644 product_class/views/product_template_views.xml diff --git a/product_class/README.rst b/product_class/README.rst new file mode 100644 index 00000000000..8e5e0e9f1fa --- /dev/null +++ b/product_class/README.rst @@ -0,0 +1,95 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============= +Product Class +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8dfa7798b73efea62709ac4d1d0249c5513baad66528c6adb67521cee96e68e7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/19.0/product_class + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-19-0/product-attribute-19-0-product_class + :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-attribute&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module introduces Product Classes to group product attributes and +standardize product setup. + +Each product class defines the attributes that are allowed for products +assigned to that class. On products, a class can be selected, and Odoo +enforces that attribute lines only use attributes from the selected +class. + +The module also provides menu entries and views to manage product +classes. + +**Table of contents** + +.. contents:: + :local: + +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 + +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. + +.. |maintainer-Ricardoalso| image:: https://github.com/Ricardoalso.png?size=40px + :target: https://github.com/Ricardoalso + :alt: Ricardoalso +.. |maintainer-ivantodorovich| image:: https://github.com/ivantodorovich.png?size=40px + :target: https://github.com/ivantodorovich + :alt: ivantodorovich + +Current `maintainers `__: + +|maintainer-Ricardoalso| |maintainer-ivantodorovich| + +This module is part of the `OCA/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_class/__init__.py b/product_class/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/product_class/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_class/__manifest__.py b/product_class/__manifest__.py new file mode 100644 index 00000000000..4c5a5476e86 --- /dev/null +++ b/product_class/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Product Class", + "version": "19.0.1.0.0", + "summary": "Product classification and attribute constraints", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "license": "AGPL-3", + "category": "Product", + "depends": ["product", "stock", "sale"], + "data": [ + "security/ir.model.access.csv", + "views/product_class_views.xml", + "views/product_attribute_views.xml", + "views/product_template_views.xml", + ], + "installable": True, + "maintainers": ["Ricardoalso", "ivantodorovich"], +} diff --git a/product_class/models/__init__.py b/product_class/models/__init__.py new file mode 100644 index 00000000000..5744891bbce --- /dev/null +++ b/product_class/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_attribute +from . import product_class +from . import product_template diff --git a/product_class/models/product_attribute.py b/product_class/models/product_attribute.py new file mode 100644 index 00000000000..3ac7f9ab2cd --- /dev/null +++ b/product_class/models/product_attribute.py @@ -0,0 +1,32 @@ +from odoo import api, fields, models + + +class ProductAttribute(models.Model): + _inherit = "product.attribute" + + classes_count = fields.Integer( + string="Product Classes Count", + compute="_compute_classes_count", + ) + + class_ids = fields.Many2many( + comodel_name="product.class", + relation="product_class_attribute_rel", + column1="attribute_id", + column2="product_class_id", + string="Product Classes", + help="Product classes that include this attribute", + ) + + @api.depends("class_ids") + def _compute_classes_count(self): + for attribute in self: + attribute.classes_count = len(attribute.class_ids) + + def action_open_product_classes(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "product_class.product_class_action" + ) + action["domain"] = [("id", "in", self.class_ids.ids)] + return action diff --git a/product_class/models/product_class.py b/product_class/models/product_class.py new file mode 100644 index 00000000000..338dec0fe86 --- /dev/null +++ b/product_class/models/product_class.py @@ -0,0 +1,48 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class ProductClass(models.Model): + _name = "product.class" + _description = "Product Class" + _order = "name" + + _name_uniq = models.Constraint( + "unique(name)", + "A product class with this name already exists.", + ) + + name = fields.Char() + attribute_ids = fields.Many2many( + comodel_name="product.attribute", + relation="product_class_attribute_rel", + column1="product_class_id", + column2="attribute_id", + string="Attributes", + help="Allowed attributes for products of this class", + ) + + @api.constrains("attribute_ids") + def _check_attribute_ids_used_by_products(self): + for product_class in self: + invalid_lines = self.env["product.template.attribute.line"].search( + [ + ("product_tmpl_id.class_id", "=", product_class.id), + ("attribute_id", "not in", product_class.attribute_ids.ids), + ] + ) + if not invalid_lines: + continue + + invalid_names = ", ".join( + sorted(set(invalid_lines.mapped("attribute_id.display_name"))) + ) + raise ValidationError( + self.env._( + "Cannot remove attributes used in products assigned to class " + "'%(product_class)s': %(attrs)s. Please remove these attributes " + "or change the product class.", + product_class=product_class.name, + attrs=invalid_names, + ) + ) diff --git a/product_class/models/product_template.py b/product_class/models/product_template.py new file mode 100644 index 00000000000..a75b423f351 --- /dev/null +++ b/product_class/models/product_template.py @@ -0,0 +1,45 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + class_id = fields.Many2one( + comodel_name="product.class", + string="Product Class", + help="Product class that constrains which attributes can be used", + ) + + class_attribute_ids = fields.Many2many( + related="class_id.attribute_ids", + string="Class Attributes", + help="Attributes allowed by the selected product class", + ) + + @api.constrains("class_id", "attribute_line_ids") + def _check_class_attributes(self): + """ + Ensure all attribute_line_ids belong to the selected class. + """ + for product in self: + if not product.class_id: + continue + + class_attributes = product.class_id.attribute_ids + invalid_attributes = ( + product.attribute_line_ids.attribute_id - class_attributes + ) + + if invalid_attributes: + invalid_names = ", ".join(invalid_attributes.mapped("display_name")) + raise ValidationError( + self.env._( + "Product '%(product)s' has attribute lines that do not belong " + "to the selected class '%(product_class)s': %(attrs)s. " + "Please remove these attributes or change the product class.", + product=product.name, + product_class=product.class_id.name, + attrs=invalid_names, + ) + ) diff --git a/product_class/pyproject.toml b/product_class/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/product_class/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_class/readme/DESCRIPTION.md b/product_class/readme/DESCRIPTION.md new file mode 100644 index 00000000000..f377a8de5fa --- /dev/null +++ b/product_class/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module introduces Product Classes to group product attributes and standardize product setup. + +Each product class defines the attributes that are allowed for products assigned to that class. On products, a class can be selected, and Odoo enforces that attribute lines only use attributes from the selected class. + +The module also provides menu entries and views to manage product classes. diff --git a/product_class/security/ir.model.access.csv b/product_class/security/ir.model.access.csv new file mode 100644 index 00000000000..0efc5ceb725 --- /dev/null +++ b/product_class/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_create,perm_read,perm_write,perm_unlink +access_product_class_user,product.class User,model_product_class,base.group_user,1,1,1,0 +access_product_class_manager,product.class Manager,model_product_class,base.group_system,1,1,1,1 diff --git a/product_class/static/description/index.html b/product_class/static/description/index.html new file mode 100644 index 00000000000..e3bc3b2d903 --- /dev/null +++ b/product_class/static/description/index.html @@ -0,0 +1,431 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Product Class

+ +

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

+

This module introduces Product Classes to group product attributes and +standardize product setup.

+

Each product class defines the attributes that are allowed for products +assigned to that class. On products, a class can be selected, and Odoo +enforces that attribute lines only use attributes from the selected +class.

+

The module also provides menu entries and views to manage product +classes.

+

Table of contents

+ +
+

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
  • +
+
+
+

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.

+

Current maintainers:

+

Ricardoalso ivantodorovich

+

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

+

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

+
+
+
+
+ + diff --git a/product_class/tests/__init__.py b/product_class/tests/__init__.py new file mode 100644 index 00000000000..c3d1af53a66 --- /dev/null +++ b/product_class/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_class diff --git a/product_class/tests/test_product_class.py b/product_class/tests/test_product_class.py new file mode 100644 index 00000000000..09d4b2fa834 --- /dev/null +++ b/product_class/tests/test_product_class.py @@ -0,0 +1,227 @@ +from psycopg2 import IntegrityError + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +def _make_attr_line(env, product, attribute, values): + """Helper: create a product.template.attribute.line with required values.""" + return env["product.template.attribute.line"].create( + { + "product_tmpl_id": product.id, + "attribute_id": attribute.id, + "value_ids": [Command.set(values.ids)], + } + ) + + +class TestProductClass(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.size_attr = cls.env["product.attribute"].create({"name": "Size"}) + cls.size_value = cls.env["product.attribute.value"].create( + {"name": "M", "attribute_id": cls.size_attr.id} + ) + cls.color_attr = cls.env["product.attribute"].create({"name": "Color"}) + cls.color_value = cls.env["product.attribute.value"].create( + {"name": "Red", "attribute_id": cls.color_attr.id} + ) + + def test_product_with_compatible_class(self): + """ + Assigning a class whose attribute matches + the product's attribute line passes. + """ + product_class = self.env["product.class"].create( + {"name": "Test Class", "attribute_ids": [Command.set([self.size_attr.id])]} + ) + product = self.env["product.template"].create({"name": "Product 1"}) + _make_attr_line(self.env, product, self.size_attr, self.size_value) + + product.write({"class_id": product_class.id}) + + self.assertEqual(product.class_id, product_class) + + def test_constraint_raises_on_incompatible_attribute_write(self): + """ + Constraint raises when writing an incompatible attribute + to a product with a class. + """ + product_class = self.env["product.class"].create( + { + "name": "Test Class 2", + "attribute_ids": [Command.set([self.size_attr.id])], + } + ) + product = self.env["product.template"].create({"name": "Product 2"}) + _make_attr_line(self.env, product, self.size_attr, self.size_value) + product.class_id = product_class # OK — size is in class + + with self.assertRaisesRegex( + ValidationError, "do not belong to the selected class" + ): + product.write( + { + "attribute_line_ids": [ + Command.create( + { + "attribute_id": self.color_attr.id, + "value_ids": [Command.set([self.color_value.id])], + } + ) + ] + } + ) + + def test_constraint_raises_on_incompatible_class_change(self): + """ + Constraint raises when class_id is changed to + one that excludes existing lines. + """ + color_class = self.env["product.class"].create( + { + "name": "Color Class", + "attribute_ids": [Command.set([self.color_attr.id])], + } + ) + product = self.env["product.template"].create({"name": "Product 3"}) + _make_attr_line(self.env, product, self.color_attr, self.color_value) + product.class_id = color_class # OK — color is in class + + size_class = self.env["product.class"].create( + {"name": "Size Class", "attribute_ids": [Command.set([self.size_attr.id])]} + ) + with self.assertRaisesRegex( + ValidationError, + "Please remove these attributes or change the product class", + ): + product.class_id = size_class # color is NOT in size_class → should raise + + def test_clearing_class_id_removes_constraint(self): + """Removing class_id from a product allows any attribute afterwards.""" + product_class = self.env["product.class"].create( + {"name": "Size Only", "attribute_ids": [Command.set([self.size_attr.id])]} + ) + product = self.env["product.template"].create({"name": "Product 5"}) + _make_attr_line(self.env, product, self.size_attr, self.size_value) + product.class_id = product_class + + with self.assertRaisesRegex( + ValidationError, "do not belong to the selected class" + ): + product.write( + { + "attribute_line_ids": [ + Command.create( + { + "attribute_id": self.color_attr.id, + "value_ids": [Command.set([self.color_value.id])], + } + ) + ] + } + ) + + product.class_id = False + + # Now color (not in the former class) should be allowed + product.write( + { + "attribute_line_ids": [ + Command.create( + { + "attribute_id": self.color_attr.id, + "value_ids": [Command.set([self.color_value.id])], + } + ) + ] + } + ) + + def test_class_with_no_attributes_rejects_any_attribute_line(self): + """A class with no attributes prevents adding any attribute lines.""" + empty_class = self.env["product.class"].create({"name": "Empty Class"}) + product = self.env["product.template"].create( + {"name": "Product 6", "class_id": empty_class.id} + ) + + with self.assertRaisesRegex( + ValidationError, "do not belong to the selected class" + ): + product.write( + { + "attribute_line_ids": [ + Command.create( + { + "attribute_id": self.size_attr.id, + "value_ids": [Command.set([self.size_value.id])], + } + ) + ] + } + ) + + def test_classed_product_with_no_attribute_lines_is_valid(self): + """A product with a class but no attribute lines is valid.""" + product_class = self.env["product.class"].create( + {"name": "Hammers", "attribute_ids": [Command.set([self.size_attr.id])]} + ) + product = self.env["product.template"].create( + {"name": "Product 7", "class_id": product_class.id} + ) + self.assertEqual(product.class_id, product_class) + self.assertFalse(product.attribute_line_ids) + + def test_cannot_remove_class_attribute_used_in_products(self): + """Removing an attribute from class is forbidden if products still use it.""" + product_class = self.env["product.class"].create( + { + "name": "Furniture Class", + "attribute_ids": [ + Command.set([self.size_attr.id, self.color_attr.id]), + ], + } + ) + product_1 = self.env["product.template"].create( + {"name": "Chair", "class_id": product_class.id} + ) + product_2 = self.env["product.template"].create( + {"name": "Table", "class_id": product_class.id} + ) + _make_attr_line(self.env, product_1, self.size_attr, self.size_value) + _make_attr_line(self.env, product_1, self.color_attr, self.color_value) + _make_attr_line(self.env, product_2, self.color_attr, self.color_value) + + with self.assertRaisesRegex( + ValidationError, + "Please remove these attributes or change the product class", + ): + product_class.write({"attribute_ids": [Command.set([self.size_attr.id])]}) + + def test_attribute_classes_count_updates_from_linked_classes(self): + """Attribute class counters reflect the linked product classes.""" + product_class = self.env["product.class"].create( + { + "name": "Counted Class", + "attribute_ids": [ + Command.set([self.size_attr.id, self.color_attr.id]), + ], + } + ) + + self.assertEqual(self.size_attr.classes_count, 1) + self.assertEqual(self.color_attr.classes_count, 1) + + product_class.write({"attribute_ids": [Command.set([self.size_attr.id])]}) + + self.assertEqual(self.size_attr.classes_count, 1) + self.assertEqual(self.color_attr.classes_count, 0) + + def test_unique_name_constraint(self): + """Creating two product classes with the same name raises an integrity error.""" + self.env["product.class"].create({"name": "Unique Class"}) + with self.assertRaises(IntegrityError), mute_logger("odoo.sql_db"): + self.env["product.class"].create({"name": "Unique Class"}) diff --git a/product_class/views/product_attribute_views.xml b/product_class/views/product_attribute_views.xml new file mode 100644 index 00000000000..438cd455b1b --- /dev/null +++ b/product_class/views/product_attribute_views.xml @@ -0,0 +1,23 @@ + + + + product.attribute + + + + + + + + diff --git a/product_class/views/product_class_views.xml b/product_class/views/product_class_views.xml new file mode 100644 index 00000000000..a2d9a090902 --- /dev/null +++ b/product_class/views/product_class_views.xml @@ -0,0 +1,64 @@ + + + + product.class + + + + + + + + + product.class + + + + + + + + + + product.class + +
+ + + + + + + +
+
+
+ + + Product Classes + product.class + list,form + {} + + + + + + +
diff --git a/product_class/views/product_template_views.xml b/product_class/views/product_template_views.xml new file mode 100644 index 00000000000..44666a799b9 --- /dev/null +++ b/product_class/views/product_template_views.xml @@ -0,0 +1,25 @@ + + + + product.template + + + + + + + + + [('id', 'in', parent.class_attribute_ids)] if parent.class_id else [] + + + +