From 1c0fee80b34a503e181809bbf2ce3a74064b1afc 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 | 277 ++++++++ product_class/__init__.py | 1 + product_class/__manifest__.py | 18 + product_class/models/__init__.py | 4 + product_class/models/product_attribute.py | 32 + product_class/models/product_class.py | 51 ++ .../models/product_class_attribute_line.py | 33 + product_class/models/product_template.py | 93 +++ product_class/pyproject.toml | 3 + product_class/readme/DESCRIPTION.md | 24 + product_class/readme/USAGE.md | 106 +++ product_class/security/ir.model.access.csv | 5 + product_class/static/description/index.html | 653 ++++++++++++++++++ product_class/tests/__init__.py | 1 + product_class/tests/test_product_class.py | 287 ++++++++ .../views/product_attribute_views.xml | 25 + product_class/views/product_class_views.xml | 81 +++ .../views/product_template_views.xml | 27 + 18 files changed, 1721 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_class_attribute_line.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/readme/USAGE.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..940a4f4f87f --- /dev/null +++ b/product_class/README.rst @@ -0,0 +1,277 @@ +.. 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:86503509d193aed6557f152cd812109c5ebc9c92c3d83812aa48459f5d0208f2 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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//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-/product-attribute--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= + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Product Class +------------- + +This module introduces **Product Classes** for standardizing product +setup and attribute management. It allows you to: + +1. **Define Product Classes** — Group related products into classes + (e.g., "Furniture", "Electronics") +2. **Constrain Attributes per Class** — Specify which attributes are + allowed for each class +3. **Mark Required Attributes** — Designate certain attributes as + mandatory for products in that class +4. **Enforce Validation** — Prevent products from using attributes + outside their class or missing required attributes + +Key Features +~~~~~~~~~~~~ + +- **Bridge Model (``product.class.attribute.line``)** — Manages the + relationship between classes and attributes, storing both allowed + attributes and a ``required`` flag +- **Strict Validation** — Products assigned to a class must: + + - Use only attributes defined in that class + - Provide values for all required attributes + +- **UI Enforcement** — Attribute selection in product forms is + restricted by the class domain filter +- **Management Views** — Full CRUD interface for product classes + (Inventory > Configuration > Product Classes, Sales > Configuration > + Product Classes) + +Technical Architecture +~~~~~~~~~~~~~~~~~~~~~~ + +- ``product.class`` — Main product classification model +- ``product.class.attribute.line`` — Bridge model linking classes to + attributes with a ``required`` flag +- ``product.attribute`` (inherited) — Extended with reverse one-to-many + to track which classes use it +- ``product.template`` (inherited) — Added class validation and computed + required-attribute tracking + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Usage +----- + +1. Create a Product Class +~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Navigation:** Inventory → Configuration → Product Classes (or Sales → +Configuration → Product Classes) + +1. Click **Create** button + +2. Enter a **Product Class Name** (e.g., "Chairs") + +3. In the **Attributes** section: + + - Click **Add a line** + - Select an **Attribute** from the dropdown + - Check **Required** if products of this class must define this + attribute + - Repeat to add more attributes + +4. Click **Save** + +**Example: "Furniture" Class** + +========= ======== ================================ +Attribute Required Notes +========= ======== ================================ +Color ✓ All furniture must have a color +Size ✓ Sizes vary by product +Material Optional (not all items specify) +Finish Optional polish/coating +========= ======== ================================ + +2. Assign a Product to a Class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Navigation:** Inventory → Products → Products + +1. Create or edit a product +2. Find the **Product Class** field in the Attributes & Variants tab + (added by this module) +3. Select the class (e.g., "Furniture") +4. The form will now restrict **Attributes** to only those allowed by + the class + +3. Add Attribute Lines to a Classed Product +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once a class is selected, you can add attribute lines: + +1. Go to the **Attributes & Variants** tab +2. In **Attribute Lines**, click **Add a line** +3. The **Attribute** field dropdown is now filtered to show only + class-allowed attributes +4. Select an attribute and provide values +5. **Validation will fail if:** + + - You select an attribute not in the class → Error: "has attribute + lines that do not belong" + - You leave out a required attribute → Error: "is missing required + attributes" + +4. Remove an Attribute from a Class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Scenario:** Your "Furniture" class allowed "Color" but wants to retire +it. + +1. Navigate to the class +2. In **Attributes**, find the "Color" line and delete it +3. **Odoo will raise an error if products in this class still use + Color** + + - Fix: Remove Color from all products in the class first, then retry + the class update + +Workflow Example +~~~~~~~~~~~~~~~~ + +**Step 1: Set Up "Chairs" Class** + +- Color (required) +- Size (required) +- Leg Material (optional) + +**Step 2: Create a "Wooden Chair" Product** + +- Assign class: "Chairs" +- Add attribute "Color" = Red (satisfies required) +- Add attribute "Size" = Large (satisfies required) +- Add attribute "Leg Material" = Oak (optional, still valid) +- **Save** ✓ Success + +**Step 3: Try Invalid Assignment** + +- Assign class: "Chairs" +- Add attribute "Color" = Blue (satisfies required) +- **Try to save without Size** → Error: "is missing required attributes + for the selected class 'Chairs': Size" +- Add Size = Medium +- **Save** ✓ Success + +Class Attribute Line Model +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The bridge model ``product.class.attribute.line`` stores: + +============ ======== ======================================== +Field Type Purpose +============ ======== ======================================== +class_id Many2one Product class (required, cascade delete) +attribute_id Many2one Product attribute (required) +required Boolean If true, products must define it +============ ======== ======================================== + +**Constraint:** A class cannot configure the same attribute twice +(unique on class_id + attribute_id). + +Constraints & Validations +~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Class Constraint** (``_check_attribute_ids_used_by_products``) + + - Prevents removing an attribute from a class if products still use + it + - Error: "Cannot remove attributes used in products assigned to + class..." + +2. **Product Constraint** (``_check_class_attributes``) + + - Ensures all attribute lines belong to the class + - Ensures all required attributes are defined + - Error: "Product '...' has attribute lines that do not belong to the + selected class '...'" + - Error: "Product '...' is missing required attributes for the + selected class '...'" + +Advanced: Access Control +~~~~~~~~~~~~~~~~~~~~~~~~ + +- **User (base.group_user):** Can create/read/write product classes and + their attributes +- **System/Admin (base.group_system):** Full CRUD including delete + +Product class attribute lines follow the same access rules. + +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..7bfa45a4326 --- /dev/null +++ b/product_class/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_class_attribute_line +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..aabb66bf7d0 --- /dev/null +++ b/product_class/models/product_attribute.py @@ -0,0 +1,32 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +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_attribute_line_ids = fields.One2many( + comodel_name="product.class.attribute.line", + inverse_name="attribute_id", + string="Class Attribute Lines", + help="Product classes that include this attribute.", + ) + + @api.depends("class_attribute_line_ids.class_id") + def _compute_classes_count(self): + for attribute in self: + attribute.classes_count = len(attribute.class_attribute_line_ids.class_id) + + 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_attribute_line_ids.class_id.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..264e3c52294 --- /dev/null +++ b/product_class/models/product_class.py @@ -0,0 +1,51 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +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(translate=True) + active = fields.Boolean(default=True) + attribute_line_ids = fields.One2many( + comodel_name="product.class.attribute.line", + inverse_name="class_id", + string="Attributes", + help="Allowed attributes for products of this" + "class and whether they are required.", + ) + + @api.constrains("attribute_line_ids") + def _check_attribute_ids_used_by_products(self): + for product_class in self: + class_attribute_ids = product_class.attribute_line_ids.attribute_id.ids + invalid_lines = self.env["product.template.attribute.line"].search( + [ + ("product_tmpl_id.class_id", "=", product_class.id), + ("attribute_id", "not in", class_attribute_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_class_attribute_line.py b/product_class/models/product_class_attribute_line.py new file mode 100644 index 00000000000..23b3d9919e8 --- /dev/null +++ b/product_class/models/product_class_attribute_line.py @@ -0,0 +1,33 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import fields, models + + +class ProductClassAttributeLine(models.Model): + _name = "product.class.attribute.line" + _description = "Product Class Attribute Line" + _order = "class_id, id" + + _class_attribute_uniq = models.Constraint( + "unique(class_id, attribute_id)", + "Each attribute can be configured only once per product class.", + ) + + class_id = fields.Many2one( + comodel_name="product.class", + required=True, + ondelete="cascade", + index=True, + string="Product Class", + ) + attribute_id = fields.Many2one( + comodel_name="product.attribute", + required=True, + ondelete="restrict", + index=True, + string="Attribute", + ) + required = fields.Boolean( + default=False, + help="If enabled, products of this class must define this attribute.", + ) diff --git a/product_class/models/product_template.py b/product_class/models/product_template.py new file mode 100644 index 00000000000..eec3de1db52 --- /dev/null +++ b/product_class/models/product_template.py @@ -0,0 +1,93 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +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( + comodel_name="product.attribute", + compute="_compute_class_attribute_ids", + string="Class Attributes", + help="Attributes allowed by the selected product class", + ) + + class_required_attribute_ids = fields.Many2many( + comodel_name="product.attribute", + compute="_compute_class_attribute_ids", + string="Class Required Attributes", + help="Required attributes for the selected product class.", + ) + + @api.depends( + "class_id", + "class_id.attribute_line_ids", + "class_id.attribute_line_ids.attribute_id", + "class_id.attribute_line_ids.required", + ) + def _compute_class_attribute_ids(self): + for product in self: + if not product.class_id: + product.class_attribute_ids = False + product.class_required_attribute_ids = False + continue + + class_lines = product.class_id.attribute_line_ids + product.class_attribute_ids = class_lines.attribute_id + product.class_required_attribute_ids = class_lines.filtered( + "required" + ).attribute_id + + @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_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, + ) + ) + + required_attributes = product.class_required_attribute_ids + missing_attributes = ( + required_attributes - product.attribute_line_ids.attribute_id + ) + + if missing_attributes: + missing_names = ", ".join( + sorted(missing_attributes.mapped("display_name")) + ) + raise ValidationError( + self.env._( + "Product '%(product)s' is missing required attributes " + "for the selected class '%(product_class)s': %(attrs)s.", + product=product.name, + product_class=product.class_id.name, + attrs=missing_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..bb408afa49d --- /dev/null +++ b/product_class/readme/DESCRIPTION.md @@ -0,0 +1,24 @@ +## Product Class + +This module introduces **Product Classes** for standardizing product setup and attribute management. It allows you to: + +1. **Define Product Classes** — Group related products into classes (e.g., "Furniture", "Electronics") +2. **Constrain Attributes per Class** — Specify which attributes are allowed for each class +3. **Mark Required Attributes** — Designate certain attributes as mandatory for products in that class +4. **Enforce Validation** — Prevent products from using attributes outside their class or missing required attributes + +### Key Features + +- **Bridge Model (`product.class.attribute.line`)** — Manages the relationship between classes and attributes, storing both allowed attributes and a `required` flag +- **Strict Validation** — Products assigned to a class must: + - Use only attributes defined in that class + - Provide values for all required attributes +- **UI Enforcement** — Attribute selection in product forms is restricted by the class domain filter +- **Management Views** — Full CRUD interface for product classes (Inventory > Configuration > Product Classes, Sales > Configuration > Product Classes) + +### Technical Architecture + +- `product.class` — Main product classification model +- `product.class.attribute.line` — Bridge model linking classes to attributes with a `required` flag +- `product.attribute` (inherited) — Extended with reverse one-to-many to track which classes use it +- `product.template` (inherited) — Added class validation and computed required-attribute tracking diff --git a/product_class/readme/USAGE.md b/product_class/readme/USAGE.md new file mode 100644 index 00000000000..2d47877a3d0 --- /dev/null +++ b/product_class/readme/USAGE.md @@ -0,0 +1,106 @@ +## Usage + +### 1. Create a Product Class + +**Navigation:** Inventory → Configuration → Product Classes (or Sales → Configuration → Product Classes) + +1. Click **Create** button +2. Enter a **Product Class Name** (e.g., "Chairs") +3. In the **Attributes** section: + - Click **Add a line** + - Select an **Attribute** from the dropdown + - Check **Required** if products of this class must define this attribute + - Repeat to add more attributes + +4. Click **Save** + +**Example: "Furniture" Class** + +| Attribute | Required | Notes | +|------------|----------|-------------------------------------| +| Color | ✓ | All furniture must have a color | +| Size | ✓ | Sizes vary by product | +| Material | | Optional (not all items specify) | +| Finish | | Optional polish/coating | + +### 2. Assign a Product to a Class + +**Navigation:** Inventory → Products → Products + +1. Create or edit a product +2. Find the **Product Class** field in the Attributes & Variants tab (added by this module) +3. Select the class (e.g., "Furniture") +4. The form will now restrict **Attributes** to only those allowed by the class + +### 3. Add Attribute Lines to a Classed Product + +Once a class is selected, you can add attribute lines: + +1. Go to the **Attributes & Variants** tab +2. In **Attribute Lines**, click **Add a line** +3. The **Attribute** field dropdown is now filtered to show only class-allowed attributes +4. Select an attribute and provide values +5. **Validation will fail if:** + - You select an attribute not in the class → Error: "has attribute lines that do not belong" + - You leave out a required attribute → Error: "is missing required attributes" + +### 4. Remove an Attribute from a Class + +**Scenario:** Your "Furniture" class allowed "Color" but wants to retire it. + +1. Navigate to the class +2. In **Attributes**, find the "Color" line and delete it +3. **Odoo will raise an error if products in this class still use Color** + - Fix: Remove Color from all products in the class first, then retry the class update + +### Workflow Example + +**Step 1: Set Up "Chairs" Class** +- Color (required) +- Size (required) +- Leg Material (optional) + +**Step 2: Create a "Wooden Chair" Product** +- Assign class: "Chairs" +- Add attribute "Color" = Red (satisfies required) +- Add attribute "Size" = Large (satisfies required) +- Add attribute "Leg Material" = Oak (optional, still valid) +- **Save** ✓ Success + +**Step 3: Try Invalid Assignment** +- Assign class: "Chairs" +- Add attribute "Color" = Blue (satisfies required) +- **Try to save without Size** → Error: "is missing required attributes for the selected class 'Chairs': Size" +- Add Size = Medium +- **Save** ✓ Success + +### Class Attribute Line Model + +The bridge model `product.class.attribute.line` stores: + +| Field | Type | Purpose | +|-------------|-----------|----------------------------------------| +| class_id | Many2one | Product class (required, cascade delete)| +| attribute_id| Many2one | Product attribute (required) | +| required | Boolean | If true, products must define it | + +**Constraint:** A class cannot configure the same attribute twice (unique on class_id + attribute_id). + +### Constraints & Validations + +1. **Class Constraint** (`_check_attribute_ids_used_by_products`) + - Prevents removing an attribute from a class if products still use it + - Error: "Cannot remove attributes used in products assigned to class..." + +2. **Product Constraint** (`_check_class_attributes`) + - Ensures all attribute lines belong to the class + - Ensures all required attributes are defined + - Error: "Product '...' has attribute lines that do not belong to the selected class '...'" + - Error: "Product '...' is missing required attributes for the selected class '...'" + +### Advanced: Access Control + +- **User (base.group_user):** Can create/read/write product classes and their attributes +- **System/Admin (base.group_system):** Full CRUD including delete + +Product class attribute lines follow the same access rules. diff --git a/product_class/security/ir.model.access.csv b/product_class/security/ir.model.access.csv new file mode 100644 index 00000000000..d5bb2b31efd --- /dev/null +++ b/product_class/security/ir.model.access.csv @@ -0,0 +1,5 @@ +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 +access_product_class_attribute_line_user,product.class.attribute.line User,model_product_class_attribute_line,base.group_user,1,1,1,0 +access_product_class_attribute_line_manager,product.class.attribute.line Manager,model_product_class_attribute_line,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..7b97d39b8ca --- /dev/null +++ b/product_class/static/description/index.html @@ -0,0 +1,653 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Product Class

+ +

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

+
+

Product Class

+

This module introduces Product Classes for standardizing product +setup and attribute management. It allows you to:

+
    +
  1. Define Product Classes — Group related products into classes +(e.g., “Furniture”, “Electronics”)
  2. +
  3. Constrain Attributes per Class — Specify which attributes are +allowed for each class
  4. +
  5. Mark Required Attributes — Designate certain attributes as +mandatory for products in that class
  6. +
  7. Enforce Validation — Prevent products from using attributes +outside their class or missing required attributes
  8. +
+
+

Key Features

+
    +
  • Bridge Model (``product.class.attribute.line``) — Manages the +relationship between classes and attributes, storing both allowed +attributes and a required flag
  • +
  • Strict Validation — Products assigned to a class must:
      +
    • Use only attributes defined in that class
    • +
    • Provide values for all required attributes
    • +
    +
  • +
  • UI Enforcement — Attribute selection in product forms is +restricted by the class domain filter
  • +
  • Management Views — Full CRUD interface for product classes +(Inventory > Configuration > Product Classes, Sales > Configuration > +Product Classes)
  • +
+
+
+

Technical Architecture

+
    +
  • product.class — Main product classification model
  • +
  • product.class.attribute.line — Bridge model linking classes to +attributes with a required flag
  • +
  • product.attribute (inherited) — Extended with reverse one-to-many +to track which classes use it
  • +
  • product.template (inherited) — Added class validation and computed +required-attribute tracking
  • +
+

Table of contents

+
+ +
+
+

Usage

+
+
+
+
+

Usage

+
+

1. Create a Product Class

+

Navigation: Inventory → Configuration → Product Classes (or Sales → +Configuration → Product Classes)

+
    +
  1. Click Create button
  2. +
  3. Enter a Product Class Name (e.g., “Chairs”)
  4. +
  5. In the Attributes section:
      +
    • Click Add a line
    • +
    • Select an Attribute from the dropdown
    • +
    • Check Required if products of this class must define this +attribute
    • +
    • Repeat to add more attributes
    • +
    +
  6. +
  7. Click Save
  8. +
+

Example: “Furniture” Class

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeRequiredNotes
ColorAll furniture must have a color
SizeSizes vary by product
Material Optional (not all items specify)
Finish Optional polish/coating
+
+
+

2. Assign a Product to a Class

+

Navigation: Inventory → Products → Products

+
    +
  1. Create or edit a product
  2. +
  3. Find the Product Class field in the Attributes & Variants tab +(added by this module)
  4. +
  5. Select the class (e.g., “Furniture”)
  6. +
  7. The form will now restrict Attributes to only those allowed by +the class
  8. +
+
+
+

3. Add Attribute Lines to a Classed Product

+

Once a class is selected, you can add attribute lines:

+
    +
  1. Go to the Attributes & Variants tab
  2. +
  3. In Attribute Lines, click Add a line
  4. +
  5. The Attribute field dropdown is now filtered to show only +class-allowed attributes
  6. +
  7. Select an attribute and provide values
  8. +
  9. Validation will fail if:
      +
    • You select an attribute not in the class → Error: “has attribute +lines that do not belong”
    • +
    • You leave out a required attribute → Error: “is missing required +attributes”
    • +
    +
  10. +
+
+
+

4. Remove an Attribute from a Class

+

Scenario: Your “Furniture” class allowed “Color” but wants to retire +it.

+
    +
  1. Navigate to the class
  2. +
  3. In Attributes, find the “Color” line and delete it
  4. +
  5. Odoo will raise an error if products in this class still use +Color
      +
    • Fix: Remove Color from all products in the class first, then retry +the class update
    • +
    +
  6. +
+
+
+

Workflow Example

+

Step 1: Set Up “Chairs” Class

+
    +
  • Color (required)
  • +
  • Size (required)
  • +
  • Leg Material (optional)
  • +
+

Step 2: Create a “Wooden Chair” Product

+
    +
  • Assign class: “Chairs”
  • +
  • Add attribute “Color” = Red (satisfies required)
  • +
  • Add attribute “Size” = Large (satisfies required)
  • +
  • Add attribute “Leg Material” = Oak (optional, still valid)
  • +
  • Save ✓ Success
  • +
+

Step 3: Try Invalid Assignment

+
    +
  • Assign class: “Chairs”
  • +
  • Add attribute “Color” = Blue (satisfies required)
  • +
  • Try to save without Size → Error: “is missing required attributes +for the selected class ‘Chairs’: Size”
  • +
  • Add Size = Medium
  • +
  • Save ✓ Success
  • +
+
+
+

Class Attribute Line Model

+

The bridge model product.class.attribute.line stores:

+ +++++ + + + + + + + + + + + + + + + + + + + + +
FieldTypePurpose
class_idMany2oneProduct class (required, cascade delete)
attribute_idMany2oneProduct attribute (required)
requiredBooleanIf true, products must define it
+

Constraint: A class cannot configure the same attribute twice +(unique on class_id + attribute_id).

+
+
+

Constraints & Validations

+
    +
  1. Class Constraint (_check_attribute_ids_used_by_products)
      +
    • Prevents removing an attribute from a class if products still use +it
    • +
    • Error: “Cannot remove attributes used in products assigned to +class…”
    • +
    +
  2. +
  3. Product Constraint (_check_class_attributes)
      +
    • Ensures all attribute lines belong to the class
    • +
    • Ensures all required attributes are defined
    • +
    • Error: “Product ‘…’ has attribute lines that do not belong to the +selected class ‘…’”
    • +
    • Error: “Product ‘…’ is missing required attributes for the +selected class ‘…’”
    • +
    +
  4. +
+
+
+

Advanced: Access Control

+
    +
  • User (base.group_user): Can create/read/write product classes and +their attributes
  • +
  • System/Admin (base.group_system): Full CRUD including delete
  • +
+

Product class attribute lines follow the same access rules.

+
+

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..9d571edaa48 --- /dev/null +++ b/product_class/tests/test_product_class.py @@ -0,0 +1,287 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +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 + + +class TestProductClass(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + 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 _create_product_attribute_line(self, product, attribute, values): + return self.env["product.template.attribute.line"].create( + { + "product_tmpl_id": product.id, + "attribute_id": attribute.id, + "value_ids": [Command.set(values.ids)], + } + ) + + def _make_class_attribute_line(self, attribute, required=False): + return Command.create( + { + "attribute_id": attribute.id, + "required": required, + } + ) + + def _create_product_class(self, name, line_commands): + return self.env["product.class"].create( + { + "name": name, + "attribute_line_ids": line_commands, + } + ) + + def test_product_with_compatible_class(self): + """A product can use a class when its attributes match.""" + product_class = self._create_product_class( + "Test Class", + [self._make_class_attribute_line(self.size_attr, required=True)], + ) + product = self.env["product.template"].create({"name": "Product 1"}) + self._create_product_attribute_line(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): + """A classed product cannot add attributes outside its class.""" + product_class = self._create_product_class( + "Test Class 2", [self._make_class_attribute_line(self.size_attr)] + ) + product = self.env["product.template"].create({"name": "Product 2"}) + self._create_product_attribute_line(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])], + } + ) + ] + } + ) + + def test_constraint_raises_on_incompatible_class_change(self): + """Changing to an incompatible class must raise a validation error.""" + color_class = self._create_product_class( + "Color Class", [self._make_class_attribute_line(self.color_attr)] + ) + product = self.env["product.template"].create({"name": "Product 3"}) + self._create_product_attribute_line(product, self.color_attr, self.color_value) + product.class_id = color_class + + size_class = self._create_product_class( + "Size Class", [self._make_class_attribute_line(self.size_attr)] + ) + with self.assertRaisesRegex( + ValidationError, + "Please remove these attributes or change the product class", + ): + product.class_id = size_class + + def test_clearing_class_id_removes_constraint(self): + """Removing class_id from a product allows any attribute afterwards.""" + product_class = self._create_product_class( + "Size Only", [self._make_class_attribute_line(self.size_attr)] + ) + product = self.env["product.template"].create({"name": "Product 5"}) + self._create_product_attribute_line(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 + + 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._create_product_class( + "Hammers", [self._make_class_attribute_line(self.size_attr)] + ) + 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._create_product_class( + "Furniture Class", + [ + self._make_class_attribute_line(self.size_attr), + self._make_class_attribute_line(self.color_attr), + ], + ) + 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} + ) + self._create_product_attribute_line(product_1, self.size_attr, self.size_value) + self._create_product_attribute_line( + product_1, self.color_attr, self.color_value + ) + self._create_product_attribute_line( + 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_line_ids": [ + Command.clear(), + self._make_class_attribute_line(self.size_attr), + ] + } + ) + + def test_attribute_classes_count_updates_from_linked_classes(self): + """Attribute class counters reflect the linked product classes.""" + product_class = self._create_product_class( + "Counted Class", + [ + self._make_class_attribute_line(self.size_attr), + self._make_class_attribute_line(self.color_attr), + ], + ) + + self.assertEqual(self.size_attr.classes_count, 1) + self.assertEqual(self.color_attr.classes_count, 1) + + product_class.write( + { + "attribute_line_ids": [ + Command.clear(), + self._make_class_attribute_line(self.size_attr), + ] + } + ) + + 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"}) + + def test_required_attributes_must_be_defined_on_products(self): + """Products with class_id must define all required class attributes.""" + product_class = self._create_product_class( + "Required Class", + [ + self._make_class_attribute_line(self.size_attr, required=True), + self._make_class_attribute_line(self.color_attr), + ], + ) + product = self.env["product.template"].create({"name": "Product 8"}) + self._create_product_attribute_line(product, self.color_attr, self.color_value) + + with self.assertRaisesRegex( + ValidationError, + "is missing required attributes for the selected class", + ): + product.class_id = product_class + + def test_required_attributes_accept_product_when_defined(self): + """Products can be assigned to class when all required attributes exist.""" + product_class = self._create_product_class( + "Required Class 2", + [self._make_class_attribute_line(self.size_attr, required=True)], + ) + product = self.env["product.template"].create({"name": "Product 9"}) + self._create_product_attribute_line(product, self.size_attr, self.size_value) + + product.class_id = product_class + # No exception should be raised + self.assertEqual(product.class_id, product_class) + + def test_class_attribute_line_is_unique_per_class(self): + """A class cannot configure the same attribute more than once.""" + with self.assertRaises(IntegrityError), mute_logger("odoo.sql_db"): + self._create_product_class( + "Duplicate Attr Class", + [ + self._make_class_attribute_line(self.size_attr), + self._make_class_attribute_line(self.size_attr), + ], + ) diff --git a/product_class/views/product_attribute_views.xml b/product_class/views/product_attribute_views.xml new file mode 100644 index 00000000000..b9c53e582b7 --- /dev/null +++ b/product_class/views/product_attribute_views.xml @@ -0,0 +1,25 @@ + + + + + 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..80b3c303077 --- /dev/null +++ b/product_class/views/product_class_views.xml @@ -0,0 +1,81 @@ + + + + + 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..fae1830ed98 --- /dev/null +++ b/product_class/views/product_template_views.xml @@ -0,0 +1,27 @@ + + + + + product.template + + + + + + + + + [('id', 'in', parent.class_attribute_ids)] if parent.class_id else [] + + + +