diff --git a/website_sale_comparison_specification_exclusion/README.rst b/website_sale_comparison_specification_exclusion/README.rst new file mode 100644 index 0000000000..bf392a43c5 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/README.rst @@ -0,0 +1,103 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============================================== +Website Sale Comparison Specification Exclusion +=============================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ce56b74965377f0c4f77763a31e9f81161d228254fabdf32c983fc96c5c6c84f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/19.0/website_sale_comparison_specification_exclusion + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-19-0/e-commerce-19-0-website_sale_comparison_specification_exclusion + :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/e-commerce&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +On the eCommerce product page, the "Specifications" table added by +``website_sale_comparison`` lists every attribute of the product, +including non-variant-defining ("informative") attributes whose values +can be restricted to specific variant combinations via the attribute +value's "Exclude for" configuration. + +Without this module, that table always lists every configured value of +such an attribute, and never updates when the customer changes the +selected variant. This module makes it show only the value(s) actually +compatible with the combination currently selected on the page, and +refreshes that table when the customer changes variant. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to *Website > Products > Attributes*. +2. On a non-variant-defining attribute's value, configure "Exclude for" + to list the variant-defining value(s) it is incompatible with. +3. On the eCommerce product page, the "Specifications" table will only + show the value(s) of that attribute compatible with the currently + selected variant, and will update automatically when the customer + changes it. + +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 + +Contributors +------------ + +- `Camptocamp `__: + + - Ricardoalso + +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/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_comparison_specification_exclusion/__init__.py b/website_sale_comparison_specification_exclusion/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/website_sale_comparison_specification_exclusion/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_sale_comparison_specification_exclusion/__manifest__.py b/website_sale_comparison_specification_exclusion/__manifest__.py new file mode 100644 index 0000000000..444e48c0a8 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Website Sale Comparison Specification Exclusion", + "version": "19.0.1.0.0", + "development_status": "Beta", + "category": "Website", + "website": "https://github.com/OCA/e-commerce", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "website_sale_comparison", + ], + "data": [ + "views/templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_sale_comparison_specification_exclusion/static/src/interactions/**/*", + ], + }, +} diff --git a/website_sale_comparison_specification_exclusion/controllers/__init__.py b/website_sale_comparison_specification_exclusion/controllers/__init__.py new file mode 100644 index 0000000000..ca58e65bf7 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/controllers/__init__.py @@ -0,0 +1 @@ +from . import variant diff --git a/website_sale_comparison_specification_exclusion/controllers/variant.py b/website_sale_comparison_specification_exclusion/controllers/variant.py new file mode 100644 index 0000000000..4712a5ebb3 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/controllers/variant.py @@ -0,0 +1,43 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.http import request, route + +from odoo.addons.website_sale.controllers.variant import WebsiteSaleVariantController + + +class WebsiteSaleSpecificationExclusionVariantController(WebsiteSaleVariantController): + @route( + "/website_sale/get_combination_info", + type="jsonrpc", + auth="public", + methods=["POST"], + website=True, + readonly=True, + ) + def get_combination_info_website( + self, + product_template_id, + product_id, + combination, + add_qty, + uom_id=None, + **kwargs, + ): + combination_info = super().get_combination_info_website( + product_template_id=product_template_id, + product_id=product_id, + combination=combination, + add_qty=add_qty, + uom_id=uom_id, + **kwargs, + ) + product_template = request.env["product.template"].browse( + int(product_template_id) + ) + specs_table_html = product_template._get_specs_table_html( + request.env["product.template.attribute.value"].browse(combination) + ) + if specs_table_html is not None: + combination_info["specs_table_html"] = specs_table_html + return combination_info diff --git a/website_sale_comparison_specification_exclusion/models/__init__.py b/website_sale_comparison_specification_exclusion/models/__init__.py new file mode 100644 index 0000000000..a75937b9c9 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_template +from . import product_template_attribute_line diff --git a/website_sale_comparison_specification_exclusion/models/product_template.py b/website_sale_comparison_specification_exclusion/models/product_template.py new file mode 100644 index 0000000000..ad268731df --- /dev/null +++ b/website_sale_comparison_specification_exclusion/models/product_template.py @@ -0,0 +1,43 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def _get_display_attribute_exclusions(self): + self.ensure_one() + return self._complete_inverse_exclusions(self._get_own_attribute_exclusions()) + + def _get_specs_table_html(self, combination): + """Render the specs table content for the given `combination` + (recordset of `product.template.attribute.value`), so it can be + refreshed client-side when the customer changes variant. + + Called only from the `/website_sale/get_combination_info` controller + """ + self.ensure_one() + has_filterable_line = any( + ptal.attribute_id.create_variant == "no_variant" and len(ptal.value_ids) > 1 + for ptal in self.valid_product_template_attribute_line_ids + ) + if not has_filterable_line: + return None + attribute_lines = self.valid_product_template_attribute_line_ids + attrib_categories = ( + attribute_lines._prepare_categories_for_display_in_specs_table() + ) + if not attrib_categories: + return None + exclusions = self._get_display_attribute_exclusions() + return self.env["ir.qweb"]._render( + "website_sale_comparison_specification_exclusion" + ".product_specifications_content", + { + "attrib_categories": attrib_categories, + "combination": combination, + "attribute_exclusions": exclusions, + }, + ) diff --git a/website_sale_comparison_specification_exclusion/models/product_template_attribute_line.py b/website_sale_comparison_specification_exclusion/models/product_template_attribute_line.py new file mode 100644 index 0000000000..5de499a27b --- /dev/null +++ b/website_sale_comparison_specification_exclusion/models/product_template_attribute_line.py @@ -0,0 +1,24 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplateAttributeLine(models.Model): + _inherit = "product.template.attribute.line" + + def _get_display_value_ids(self, combination, exclusions=None): + self.ensure_one() + if not combination or self.attribute_id.create_variant != "no_variant": + return self.value_ids + if exclusions is None: + exclusions = self.product_tmpl_id._get_display_attribute_exclusions() + other_ids = set(combination.ids) - set(self.product_template_value_ids.ids) + displayed_value_ids = set() + for ptav in self.product_template_value_ids: + conflicts = any( + other_id in exclusions.get(ptav.id, []) for other_id in other_ids + ) + if not conflicts: + displayed_value_ids.add(ptav.product_attribute_value_id.id) + return self.env["product.attribute.value"].browse(displayed_value_ids).exists() diff --git a/website_sale_comparison_specification_exclusion/pyproject.toml b/website_sale_comparison_specification_exclusion/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_comparison_specification_exclusion/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_comparison_specification_exclusion/readme/CONTRIBUTORS.md b/website_sale_comparison_specification_exclusion/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..f27dfd8053 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Camptocamp](https://www.camptocamp.com/): + - Ricardoalso \<\> diff --git a/website_sale_comparison_specification_exclusion/readme/DESCRIPTION.md b/website_sale_comparison_specification_exclusion/readme/DESCRIPTION.md new file mode 100644 index 0000000000..59cbd1c447 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +On the eCommerce product page, the "Specifications" table added by +`website_sale_comparison` lists every attribute of the product, +including non-variant-defining ("informative") attributes whose values +can be restricted to specific variant combinations via the attribute +value's "Exclude for" configuration. + +Without this module, that table always lists every configured value of +such an attribute, and never updates when the customer changes the +selected variant. This module makes it show only the value(s) actually +compatible with the combination currently selected on the page, and +refreshes that table when the customer changes variant. diff --git a/website_sale_comparison_specification_exclusion/readme/USAGE.md b/website_sale_comparison_specification_exclusion/readme/USAGE.md new file mode 100644 index 0000000000..99d8ddb034 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/readme/USAGE.md @@ -0,0 +1,7 @@ +1. Go to *Website \> Products \> Attributes*. +2. On a non-variant-defining attribute's value, configure "Exclude for" + to list the variant-defining value(s) it is incompatible with. +3. On the eCommerce product page, the "Specifications" table will only + show the value(s) of that attribute compatible with the currently + selected variant, and will update automatically when the customer + changes it. diff --git a/website_sale_comparison_specification_exclusion/static/description/index.html b/website_sale_comparison_specification_exclusion/static/description/index.html new file mode 100644 index 0000000000..bd2f009593 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/static/description/index.html @@ -0,0 +1,454 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Sale Comparison Specification Exclusion

+ +

Beta License: AGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

On the eCommerce product page, the “Specifications” table added by +website_sale_comparison lists every attribute of the product, +including non-variant-defining (“informative”) attributes whose values +can be restricted to specific variant combinations via the attribute +value’s “Exclude for” configuration.

+

Without this module, that table always lists every configured value of +such an attribute, and never updates when the customer changes the +selected variant. This module makes it show only the value(s) actually +compatible with the combination currently selected on the page, and +refreshes that table when the customer changes variant.

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to Website > Products > Attributes.
  2. +
  3. On a non-variant-defining attribute’s value, configure “Exclude for” +to list the variant-defining value(s) it is incompatible with.
  4. +
  5. On the eCommerce product page, the “Specifications” table will only +show the value(s) of that attribute compatible with the currently +selected variant, and will update automatically when the customer +changes it.
  6. +
+
+
+

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.

+

This module is part of the OCA/e-commerce project on GitHub.

+

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

+
+
+
+
+ + diff --git a/website_sale_comparison_specification_exclusion/static/src/interactions/website_sale.esm.js b/website_sale_comparison_specification_exclusion/static/src/interactions/website_sale.esm.js new file mode 100644 index 0000000000..dff08494dc --- /dev/null +++ b/website_sale_comparison_specification_exclusion/static/src/interactions/website_sale.esm.js @@ -0,0 +1,34 @@ +import {WebsiteSale} from "@website_sale/interactions/website_sale"; +import {markup} from "@odoo/owl"; +import {patch} from "@web/core/utils/patch"; +import {setElementContent} from "@web/core/utils/html"; + +patch(WebsiteSale.prototype, { + /** + * Adds the specs table refresh to the regular _onChangeCombination method + * @override + */ + _onChangeCombination(...args) { + super._onChangeCombination(...args); + this._onChangeCombinationSpecsTable(...args); + }, + + /** + * Refreshes the "Specifications" table so that non-variant-defining + * attribute values excluded for the newly selected combination stop + * being displayed. + * + * @param {MouseEvent} ev + * @param {Element} parent + * @param {Array} combination + */ + _onChangeCombinationSpecsTable(ev, parent, combination) { + if (combination.specs_table_html === undefined) { + return; + } + const specsTableEl = document.querySelector("#product_specifications"); + if (specsTableEl) { + setElementContent(specsTableEl, markup(combination.specs_table_html)); + } + }, +}); diff --git a/website_sale_comparison_specification_exclusion/tests/__init__.py b/website_sale_comparison_specification_exclusion/tests/__init__.py new file mode 100644 index 0000000000..52b34b5dcb --- /dev/null +++ b/website_sale_comparison_specification_exclusion/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_comparison_specification_exclusion diff --git a/website_sale_comparison_specification_exclusion/tests/test_website_sale_comparison_specification_exclusion.py b/website_sale_comparison_specification_exclusion/tests/test_website_sale_comparison_specification_exclusion.py new file mode 100644 index 0000000000..7ca3008174 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/tests/test_website_sale_comparison_specification_exclusion.py @@ -0,0 +1,254 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.fields import Command +from odoo.tests import TransactionCase, tagged + + +@tagged("-at_install", "post_install") +class TestWebsiteSaleComparisonSpecificationExclusion(TransactionCase): + def test_display_value_ids_ignores_variant_defining_lines(self): + """A variant-defining attribute must keep listing all of its + values in the specs table, even if some values are excluded for + other values of the current combination (e.g. Legs: Aluminium + excludes Color: Black) -- that exclusion only restricts which + variants exist, it must not hide "Black" from the specs overview, + since the product is still offered in that color.""" + legs_attribute = self.env["product.attribute"].create( + { + "name": "Legs", + "value_ids": [ + Command.create({"name": "Steel"}), + Command.create({"name": "Aluminium"}), + ], + } + ) + color_attribute = self.env["product.attribute"].create( + { + "name": "Color", + "value_ids": [ + Command.create({"name": "White"}), + Command.create({"name": "Black"}), + ], + } + ) + product = self.env["product.template"].create( + { + "name": "Desk", + "attribute_line_ids": [ + Command.create( + { + "attribute_id": legs_attribute.id, + "value_ids": [Command.set(legs_attribute.value_ids.ids)], + } + ), + Command.create( + { + "attribute_id": color_attribute.id, + "value_ids": [Command.set(color_attribute.value_ids.ids)], + } + ), + ], + } + ) + legs_line, color_line = product.attribute_line_ids + steel, aluminium = legs_line.product_template_value_ids + white, black = color_line.product_template_value_ids + aluminium.exclude_for = [ + Command.create( + { + "product_tmpl_id": product.id, + "value_ids": [Command.link(black.id)], + } + ) + ] + + displayed = color_line._get_display_value_ids(aluminium) + + self.assertEqual( + displayed, + white.product_attribute_value_id | black.product_attribute_value_id, + ) + + def test_display_value_ids_ignores_unrelated_conflicts_in_combination(self): + """A non-variant-defining line with no `exclude_for` of its own must + keep showing all of its values, even when the rest of the given + combination is itself internally conflicting (e.g. two mutually + exclusive variant-defining values were both selected). The + unrelated conflict must not be mistaken for a conflict involving + this line's own candidates.""" + color_attribute = self.env["product.attribute"].create( + { + "name": "Color", + "value_ids": [ + Command.create({"name": "Red"}), + ], + } + ) + size_attribute = self.env["product.attribute"].create( + { + "name": "Size", + "value_ids": [ + Command.create({"name": "1"}), + Command.create({"name": "2"}), + ], + } + ) + note_attribute = self.env["product.attribute"].create( + { + "name": "Note", + "create_variant": "no_variant", + "value_ids": [ + Command.create({"name": "Hello"}), + Command.create({"name": "World"}), + ], + } + ) + product = self.env["product.template"].create( + { + "name": "Sample", + "attribute_line_ids": [ + Command.create( + { + "attribute_id": color_attribute.id, + "value_ids": [Command.set(color_attribute.value_ids.ids)], + } + ), + Command.create( + { + "attribute_id": size_attribute.id, + "value_ids": [Command.set(size_attribute.value_ids.ids)], + } + ), + Command.create( + { + "attribute_id": note_attribute.id, + "value_ids": [Command.set(note_attribute.value_ids.ids)], + } + ), + ], + } + ) + color_line, size_line, note_line = product.attribute_line_ids + red = color_line.product_template_value_ids + size_1, size_2 = size_line.product_template_value_ids + hello, world = note_line.product_template_value_ids + # Red and Size 1 mutually exclude each other; Note has no + # exclude_for of its own at all. + red.exclude_for = [ + Command.create( + { + "product_tmpl_id": product.id, + "value_ids": [Command.link(size_1.id)], + } + ) + ] + size_1.exclude_for = [ + Command.create( + { + "product_tmpl_id": product.id, + "value_ids": [Command.link(red.id)], + } + ) + ] + + displayed = note_line._get_display_value_ids(red + size_1 + hello) + + self.assertEqual( + displayed, + hello.product_attribute_value_id | world.product_attribute_value_id, + ) + + @classmethod + def _create_sized_product_with_weight_exclusions(cls): + """A product with a Size (variant-defining) line and a Weight + (non-variant-defining) line whose two values each exclude the + opposite Size value -- the fixture shared by the tests below.""" + size_attribute = cls.env["product.attribute"].create( + { + "name": "Size", + "value_ids": [ + Command.create({"name": "Size 1"}), + Command.create({"name": "Size 2"}), + ], + } + ) + weight_attribute = cls.env["product.attribute"].create( + { + "name": "Weight", + "create_variant": "no_variant", + "value_ids": [ + Command.create({"name": "Weight for Size 1"}), + Command.create({"name": "Weight for Size 2"}), + ], + } + ) + product = cls.env["product.template"].create( + { + "name": "Sized product", + "attribute_line_ids": [ + Command.create( + { + "attribute_id": size_attribute.id, + "value_ids": [Command.set(size_attribute.value_ids.ids)], + } + ), + Command.create( + { + "attribute_id": weight_attribute.id, + "value_ids": [Command.set(weight_attribute.value_ids.ids)], + } + ), + ], + } + ) + size_line, weight_line = product.attribute_line_ids + size_1, size_2 = size_line.product_template_value_ids + weight_1, weight_2 = weight_line.product_template_value_ids + weight_1.exclude_for = [ + Command.create( + { + "product_tmpl_id": product.id, + "value_ids": [Command.link(size_2.id)], + } + ) + ] + weight_2.exclude_for = [ + Command.create( + { + "product_tmpl_id": product.id, + "value_ids": [Command.link(size_1.id)], + } + ) + ] + return product, size_1, size_2, weight_line, weight_1, weight_2 + + def test_display_value_ids_excludes_incompatible_values(self): + """A non-variant-defining attribute value excluded for a given + variant-defining combination must not be returned for display.""" + _product, size_1, size_2, weight_line, weight_1, weight_2 = ( + self._create_sized_product_with_weight_exclusions() + ) + + displayed_for_size_1 = weight_line._get_display_value_ids(size_1) + displayed_for_size_2 = weight_line._get_display_value_ids(size_2) + + self.assertEqual(displayed_for_size_1, weight_1.product_attribute_value_id) + self.assertEqual(displayed_for_size_2, weight_2.product_attribute_value_id) + + def test_get_specs_table_html_recomputes_by_combination(self): + """`_get_specs_table_html` must return content matching the given + combination, so the front-end can refresh the specs table when the + customer changes variant.""" + product, size_1, size_2, _weight_line, weight_1, weight_2 = ( + self._create_sized_product_with_weight_exclusions() + ) + + specs_size_1 = product._get_specs_table_html(size_1 + weight_1) + specs_size_2 = product._get_specs_table_html(size_2 + weight_2) + + self.assertIn("Weight for Size 1", specs_size_1) + self.assertNotIn("Weight for Size 2", specs_size_1) + self.assertIn("Weight for Size 2", specs_size_2) + self.assertNotIn("Weight for Size 1", specs_size_2) diff --git a/website_sale_comparison_specification_exclusion/views/templates.xml b/website_sale_comparison_specification_exclusion/views/templates.xml new file mode 100644 index 0000000000..5dcdde7c73 --- /dev/null +++ b/website_sale_comparison_specification_exclusion/views/templates.xml @@ -0,0 +1,57 @@ + + + + + + + + + + +