diff --git a/website_sale_comparison_specification_variant/README.rst b/website_sale_comparison_specification_variant/README.rst new file mode 100644 index 0000000000..727d85e9d9 --- /dev/null +++ b/website_sale_comparison_specification_variant/README.rst @@ -0,0 +1,114 @@ +.. 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 Variant +============================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a2fed4155335af7fb8b54154f58650955fc3793d4685c393ff041a5437cae9bd + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_variant + :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_variant + :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" display added by +``website_sale_comparison`` (shown as a table, an accordion item, or +both, depending on the theme's display options) lists every attribute of +the product, whose values can be restricted to specific variant +combinations via the attribute value's "Exclude for" configuration. + +Without this module, that display 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 it when the customer changes variant. This applies to every +attribute type, not only non-variant-defining ("informative") ones. + +The comparison page (``/shop/compare``) is covered too: for each +compared product, a non-variant-defining attribute only lists the +value(s) compatible with that product's own variant. Variant-defining +attributes are unaffected there, since the comparison page already shows +each product's own actual value for them. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to *Website > Products > Attributes*. +2. On an attribute's value, configure "Exclude for" to list the value(s) + (of any attribute type) it is incompatible with. +3. On the eCommerce product page, the "Specifications" display (table + and/or accordion item, depending on the theme's display options) will + only show the value(s) of that attribute compatible with the + currently selected variant, and will update automatically when the + customer changes it. 3.1 On the comparison page (``/shop/compare``), + each compared product's specifications will only show the value(s) of + a non-variant-defining attribute compatible with that product's own + variant. + +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_variant/__init__.py b/website_sale_comparison_specification_variant/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/website_sale_comparison_specification_variant/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_sale_comparison_specification_variant/__manifest__.py b/website_sale_comparison_specification_variant/__manifest__.py new file mode 100644 index 0000000000..1f366992e9 --- /dev/null +++ b/website_sale_comparison_specification_variant/__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 Variant", + "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_variant/static/src/interactions/**/*", + ], + }, +} diff --git a/website_sale_comparison_specification_variant/controllers/__init__.py b/website_sale_comparison_specification_variant/controllers/__init__.py new file mode 100644 index 0000000000..ca58e65bf7 --- /dev/null +++ b/website_sale_comparison_specification_variant/controllers/__init__.py @@ -0,0 +1 @@ +from . import variant diff --git a/website_sale_comparison_specification_variant/controllers/variant.py b/website_sale_comparison_specification_variant/controllers/variant.py new file mode 100644 index 0000000000..2bff524c60 --- /dev/null +++ b/website_sale_comparison_specification_variant/controllers/variant.py @@ -0,0 +1,49 @@ +# 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) + ) + combination_values = request.env["product.template.attribute.value"].browse( + combination + ) + specs_table_html = product_template._get_specs_table_html(combination_values) + if specs_table_html is not None: + combination_info["specs_table_html"] = specs_table_html + specs_accordion_html = product_template._get_specs_accordion_html( + combination_values + ) + if specs_accordion_html is not None: + combination_info["specs_accordion_html"] = specs_accordion_html + return combination_info diff --git a/website_sale_comparison_specification_variant/models/__init__.py b/website_sale_comparison_specification_variant/models/__init__.py new file mode 100644 index 0000000000..7dc96bdc23 --- /dev/null +++ b/website_sale_comparison_specification_variant/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_product +from . import product_template +from . import product_template_attribute_line diff --git a/website_sale_comparison_specification_variant/models/product_product.py b/website_sale_comparison_specification_variant/models/product_product.py new file mode 100644 index 0000000000..eaba528a1e --- /dev/null +++ b/website_sale_comparison_specification_variant/models/product_product.py @@ -0,0 +1,42 @@ +# 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 ProductProduct(models.Model): + _inherit = "product.product" + + def _prepare_categories_for_display(self): + """Same as the base method, but for non-variant-defining attributes, + only display the values compatible with each compared product's own + variant combination, instead of every configured value. + + Variant-defining attributes are left untouched: the base method + already returns each product's own actual value for those, and + filtering it through `_get_display_value_ids` would incorrectly + turn it into every value still compatible with the product's other + attributes, instead of the single value the product actually has. + """ + categories = super()._prepare_categories_for_display() + exclusions_by_template = {} + for attributes in categories.values(): + for attribute, values_by_product in attributes.items(): + if attribute.create_variant != "no_variant": + continue + for product in values_by_product: + ptal = product.attribute_line_ids.filtered( + lambda line, attribute=attribute: line.attribute_id == attribute + ) + if not ptal: + continue + template = product.product_tmpl_id + if template.id not in exclusions_by_template: + exclusions_by_template[template.id] = ( + template._get_display_attribute_exclusions() + ) + values_by_product[product] = ptal._get_display_value_ids( + product.product_template_attribute_value_ids, + exclusions_by_template[template.id], + ) + return categories diff --git a/website_sale_comparison_specification_variant/models/product_template.py b/website_sale_comparison_specification_variant/models/product_template.py new file mode 100644 index 0000000000..61b9b8435a --- /dev/null +++ b/website_sale_comparison_specification_variant/models/product_template.py @@ -0,0 +1,69 @@ +# 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_render_context(self, combination): + """Return the qweb rendering context for the specs tables matching + `combination` (recordset of `product.template.attribute.value`), or + `None` if no line has more than one value to filter. + """ + self.ensure_one() + has_filterable_line = any( + len(ptal.value_ids) > 1 + for ptal in self.valid_product_template_attribute_line_ids + ) + if not has_filterable_line: + return None + ptals = self.valid_product_template_attribute_line_ids + attrib_categories = ptals._prepare_categories_for_display_in_specs_table() + if not attrib_categories: + return None + return { + "attrib_categories": attrib_categories, + "combination": combination, + "attribute_exclusions": self._get_display_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() + render_context = self._get_specs_render_context(combination) + if render_context is None: + return None + return self.env["ir.qweb"]._render( + "website_sale_comparison_specification_variant" + ".product_specifications_content", + render_context, + ) + + def _get_specs_accordion_html(self, combination): + """Render the specs accordion content for the given `combination` + (recordset of `product.template.attribute.value`), so it can be + refreshed client-side when the customer changes variant, without + disturbing the accordion's open/collapsed state. + + Called only from the `/website_sale/get_combination_info` controller + """ + self.ensure_one() + render_context = self._get_specs_render_context(combination) + if render_context is None: + return None + return self.env["ir.qweb"]._render( + "website_sale_comparison_specification_variant" + ".product_specifications_accordion_content", + render_context, + ) diff --git a/website_sale_comparison_specification_variant/models/product_template_attribute_line.py b/website_sale_comparison_specification_variant/models/product_template_attribute_line.py new file mode 100644 index 0000000000..d27829afe1 --- /dev/null +++ b/website_sale_comparison_specification_variant/models/product_template_attribute_line.py @@ -0,0 +1,32 @@ +# 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: + return self.value_ids + if self.attribute_id.create_variant != "no_variant": + # There is no candidate set to narrow down here: `combination` + # already carries the one value selected for this line, exactly + # like `ProductProduct._prepare_categories_for_display` shows + # each compared product's own actual value. + return ( + self.product_template_value_ids & combination + ).product_attribute_value_id + 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_variant/pyproject.toml b/website_sale_comparison_specification_variant/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_comparison_specification_variant/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_comparison_specification_variant/readme/CONTRIBUTORS.md b/website_sale_comparison_specification_variant/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..f27dfd8053 --- /dev/null +++ b/website_sale_comparison_specification_variant/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Camptocamp](https://www.camptocamp.com/): + - Ricardoalso \<\> diff --git a/website_sale_comparison_specification_variant/readme/DESCRIPTION.md b/website_sale_comparison_specification_variant/readme/DESCRIPTION.md new file mode 100644 index 0000000000..152356350a --- /dev/null +++ b/website_sale_comparison_specification_variant/readme/DESCRIPTION.md @@ -0,0 +1,18 @@ +On the eCommerce product page, the "Specifications" display added by +`website_sale_comparison` (shown as a table, an accordion item, or +both, depending on the theme's display options) lists every attribute +of the product, whose values can be restricted to specific variant +combinations via the attribute value's "Exclude for" configuration. + +Without this module, that display 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 it when the customer changes variant. This applies to every +attribute type, not only non-variant-defining ("informative") ones. + +The comparison page (`/shop/compare`) is covered too: for each compared +product, a non-variant-defining attribute only lists the value(s) +compatible with that product's own variant. Variant-defining attributes +are unaffected there, since the comparison page already shows each +product's own actual value for them. diff --git a/website_sale_comparison_specification_variant/readme/USAGE.md b/website_sale_comparison_specification_variant/readme/USAGE.md new file mode 100644 index 0000000000..8a5cd0cde0 --- /dev/null +++ b/website_sale_comparison_specification_variant/readme/USAGE.md @@ -0,0 +1,11 @@ +1. Go to *Website \> Products \> Attributes*. +2. On an attribute's value, configure "Exclude for" to list the + value(s) (of any attribute type) it is incompatible with. +3. On the eCommerce product page, the "Specifications" display (table + and/or accordion item, depending on the theme's display options) + will only show the value(s) of that attribute compatible with the + currently selected variant, and will update automatically when the + customer changes it. +3.1 On the comparison page (`/shop/compare`), each compared product's + specifications will only show the value(s) of a non-variant-defining + attribute compatible with that product's own variant. diff --git a/website_sale_comparison_specification_variant/static/description/index.html b/website_sale_comparison_specification_variant/static/description/index.html new file mode 100644 index 0000000000..88842368d6 --- /dev/null +++ b/website_sale_comparison_specification_variant/static/description/index.html @@ -0,0 +1,464 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Sale Comparison Specification Variant

+ +

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

+

On the eCommerce product page, the “Specifications” display added by +website_sale_comparison (shown as a table, an accordion item, or +both, depending on the theme’s display options) lists every attribute of +the product, whose values can be restricted to specific variant +combinations via the attribute value’s “Exclude for” configuration.

+

Without this module, that display 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 it when the customer changes variant. This applies to every +attribute type, not only non-variant-defining (“informative”) ones.

+

The comparison page (/shop/compare) is covered too: for each +compared product, a non-variant-defining attribute only lists the +value(s) compatible with that product’s own variant. Variant-defining +attributes are unaffected there, since the comparison page already shows +each product’s own actual value for them.

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to Website > Products > Attributes.
  2. +
  3. On an attribute’s value, configure “Exclude for” to list the value(s) +(of any attribute type) it is incompatible with.
  4. +
  5. On the eCommerce product page, the “Specifications” display (table +and/or accordion item, depending on the theme’s display options) will +only show the value(s) of that attribute compatible with the +currently selected variant, and will update automatically when the +customer changes it. 3.1 On the comparison page (/shop/compare), +each compared product’s specifications will only show the value(s) of +a non-variant-defining attribute compatible with that product’s own +variant.
  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_variant/static/src/interactions/website_sale.esm.js b/website_sale_comparison_specification_variant/static/src/interactions/website_sale.esm.js new file mode 100644 index 0000000000..08fc3a2574 --- /dev/null +++ b/website_sale_comparison_specification_variant/static/src/interactions/website_sale.esm.js @@ -0,0 +1,62 @@ +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); + this._onChangeCombinationSpecsAccordion(...args); + }, + + /** + * Refreshes the "Specifications" table so that 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)); + } + }, + + /** + * Same as `_onChangeCombinationSpecsTable`, but for the "Specifications" + * accordion item: each category's body is refreshed individually, + * leaving the `accordion-collapse`/`accordion-header` wrappers + * untouched so the accordion's open/collapsed state survives the + * refresh. + * + * @param {MouseEvent} ev + * @param {Element} parent + * @param {Array} combination + */ + _onChangeCombinationSpecsAccordion(ev, parent, combination) { + if (combination.specs_accordion_html === undefined) { + return; + } + const categoryEls = new DOMParser() + .parseFromString(combination.specs_accordion_html, "text/html") + .querySelectorAll("[data-category-index]"); + categoryEls.forEach((categoryEl) => { + const accordionBodyEl = document.querySelector( + `#category_accordion_${categoryEl.dataset.categoryIndex} .accordion-body` + ); + if (accordionBodyEl) { + setElementContent(accordionBodyEl, markup(categoryEl.innerHTML)); + } + }); + }, +}); diff --git a/website_sale_comparison_specification_variant/tests/__init__.py b/website_sale_comparison_specification_variant/tests/__init__.py new file mode 100644 index 0000000000..a9d83070c4 --- /dev/null +++ b/website_sale_comparison_specification_variant/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_comparison_specification_variant diff --git a/website_sale_comparison_specification_variant/tests/test_website_sale_comparison_specification_variant.py b/website_sale_comparison_specification_variant/tests/test_website_sale_comparison_specification_variant.py new file mode 100644 index 0000000000..5140c003de --- /dev/null +++ b/website_sale_comparison_specification_variant/tests/test_website_sale_comparison_specification_variant.py @@ -0,0 +1,320 @@ +# 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_shows_only_the_selected_value_for_variant_defining_lines( + self, + ): + """A variant-defining attribute has no candidate set to narrow down: + the specs table must show only the value actually selected in the + current combination, exactly like the comparison page shows each + compared product's own actual value -- regardless of any + `exclude_for` configured on the other candidate values.""" + 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 + black) + + self.assertEqual(displayed, 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_prepare_categories_for_display_excludes_incompatible_values(self): + """On the comparison page, a non-variant-defining attribute value + excluded for a compared product's own variant must not be listed + for that product.""" + product, size_1, size_2, weight_line, weight_1, weight_2 = ( + self._create_sized_product_with_weight_exclusions() + ) + variant_1 = product.product_variant_ids.filtered( + lambda p: size_1 in p.product_template_attribute_value_ids + ) + variant_2 = product.product_variant_ids.filtered( + lambda p: size_2 in p.product_template_attribute_value_ids + ) + + categories = (variant_1 + variant_2)._prepare_categories_for_display() + + displayed = categories[weight_line.attribute_id.category_id][ + weight_line.attribute_id + ] + self.assertEqual(displayed[variant_1], weight_1.product_attribute_value_id) + self.assertEqual(displayed[variant_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) + + def test_get_specs_accordion_html_recomputes_by_combination(self): + """`_get_specs_accordion_html` must return content matching the + given combination, so the front-end can refresh the specs + accordion when the customer changes variant.""" + product, size_1, size_2, _weight_line, weight_1, weight_2 = ( + self._create_sized_product_with_weight_exclusions() + ) + + accordion_size_1 = product._get_specs_accordion_html(size_1 + weight_1) + accordion_size_2 = product._get_specs_accordion_html(size_2 + weight_2) + + self.assertIn("Weight for Size 1", accordion_size_1) + self.assertNotIn("Weight for Size 2", accordion_size_1) + self.assertIn("Weight for Size 2", accordion_size_2) + self.assertNotIn("Weight for Size 1", accordion_size_2) + + def test_get_specs_table_html_hides_single_custom_value_line(self): + """A non-variant-defining line whose only value is marked "custom" + must stay hidden from the refreshed specs table, matching the + initial page render (`website_sale_comparison.product_attributes_body`), + which filters it out via `_prepare_categories_for_display_in_specs_table`.""" + product, size_1, _size_2, _weight_line, weight_1, _weight_2 = ( + self._create_sized_product_with_weight_exclusions() + ) + custom_attribute = self.env["product.attribute"].create( + { + "name": "Engraving", + "create_variant": "no_variant", + "value_ids": [ + Command.create({"name": "Custom text", "is_custom": True}), + ], + } + ) + product.attribute_line_ids = [ + Command.create( + { + "attribute_id": custom_attribute.id, + "value_ids": [Command.set(custom_attribute.value_ids.ids)], + } + ) + ] + + specs_html = product._get_specs_table_html(size_1 + weight_1) + + self.assertNotIn("Engraving", specs_html) diff --git a/website_sale_comparison_specification_variant/views/templates.xml b/website_sale_comparison_specification_variant/views/templates.xml new file mode 100644 index 0000000000..4aabae536f --- /dev/null +++ b/website_sale_comparison_specification_variant/views/templates.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + +