diff --git a/product_configurator/models/product_attribute.py b/product_configurator/models/product_attribute.py
index de1f9336de..3790542130 100644
--- a/product_configurator/models/product_attribute.py
+++ b/product_configurator/models/product_attribute.py
@@ -2,6 +2,7 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
+from odoo.tools.safe_eval import test_python_expr
class ProductAttribute(models.Model):
@@ -83,6 +84,34 @@ def onchange_val_custom_field(self):
)
uom_id = fields.Many2one(comodel_name="uom.uom", string="Unit of Measure")
image = fields.Binary()
+ configurator_extra_price_formula = fields.Text(
+ string="Extra price formula",
+ help="Formula evaluated when computing "
+ "the extra price "
+ "for the custom value of this attribute.\n"
+ "The following variables are available:\n"
+ "- attribute: this attribute,\n"
+ "- config_session: the configuration session that configured the product,\n"
+ "- custom_value: the value provided by the user,\n"
+ "- product: the configured product,\n"
+ "The computed price "
+ "must be assigned to the `price` variable.",
+ )
+
+ @api.constrains(
+ "configurator_extra_price_formula",
+ )
+ def _constrain_configurator_extra_price_formula(self):
+ """Check syntax of added formula for 'exec' evaluation."""
+ for attribute in self:
+ price_formula = attribute.configurator_extra_price_formula
+ if price_formula:
+ error_message = test_python_expr(
+ expr=price_formula,
+ mode="exec",
+ )
+ if error_message:
+ raise ValidationError(error_message)
# TODO prevent the same attribute from being defined twice on the
# attribute lines
diff --git a/product_configurator/models/product_config.py b/product_configurator/models/product_config.py
index 9ff3ec7f68..fac361696a 100644
--- a/product_configurator/models/product_config.py
+++ b/product_configurator/models/product_config.py
@@ -1,9 +1,9 @@
import logging
-from ast import literal_eval
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools.misc import flatten, formatLang
+from odoo.tools.safe_eval import safe_eval
_logger = logging.getLogger(__name__)
@@ -332,6 +332,7 @@ class ProductConfigSession(models.Model):
@api.depends(
"value_ids",
+ "custom_value_ids.price",
"product_tmpl_id.list_price",
"product_tmpl_id.attribute_line_ids",
"product_tmpl_id.attribute_line_ids.value_ids",
@@ -358,12 +359,7 @@ def _get_custom_vals_dict(self):
{attribute_id: parsed_custom_value}"""
custom_vals = {}
for val in self.custom_value_ids:
- if val.attribute_id.custom_type in ["float", "integer"]:
- custom_vals[val.attribute_id.id] = literal_eval(val.value)
- elif val.attribute_id.custom_type == "binary":
- custom_vals[val.attribute_id.id] = val.attachment_ids
- else:
- custom_vals[val.attribute_id.id] = val.value
+ custom_vals[val.attribute_id.id] = val.eval()
return custom_vals
def _compute_config_step_name(self):
@@ -403,7 +399,7 @@ def get_cfg_weight(self, value_ids=None, custom_vals=None):
value_ids = self.value_ids.ids
if custom_vals is None:
- custom_vals = {}
+ custom_vals = self._get_custom_vals_dict()
product_tmpl = self.product_tmpl_id
@@ -827,20 +823,26 @@ def get_cfg_price(self, value_ids=None, custom_vals=None):
value_ids = self.value_ids.ids
if custom_vals is None:
- custom_vals = {}
+ custom_vals = self._get_custom_vals_dict()
+
+ session_custom_values = self.custom_value_ids.filtered(
+ lambda cv: cv.attribute_id.id in custom_vals.keys()
+ )
+ custom_prices = session_custom_values.mapped("price")
+
+ price_extra = sum(custom_prices)
product_tmpl = self.product_tmpl_id
self = self.with_context(active_id=product_tmpl.id)
value_ids = self.flatten_val_ids(value_ids)
- price_extra = 0.0
attr_val_obj = self.env["product.attribute.value"]
av_ids = attr_val_obj.browse(value_ids)
extra_prices = attr_val_obj.get_attribute_value_extra_prices(
product_tmpl_id=product_tmpl.id, pt_attr_value_ids=av_ids
)
- price_extra = sum(extra_prices.values())
+ price_extra += sum(extra_prices.values())
return product_tmpl.list_price + price_extra
def _get_config_image(self, value_ids=None, custom_vals=None, size=None):
@@ -1608,6 +1610,43 @@ def _compute_val_name(self):
column2="attachment_id",
string="Attachments",
)
+ price = fields.Float(
+ compute="_compute_price",
+ help="Price computed using attribute's 'Extra price formula'.",
+ )
+
+ def _eval_price_formula_variables_dict(self):
+ """Variables described in `product.attribute.configurator_extra_price_formula`."""
+ self.ensure_one()
+ return {
+ "attribute": self.attribute_id,
+ "config_session": self.cfg_session_id,
+ "custom_value": self.eval(),
+ "product": self.cfg_session_id.product_id,
+ }
+
+ def _eval_price_formula(self):
+ self.ensure_one()
+ price_formula = self.attribute_id.configurator_extra_price_formula
+ if price_formula:
+ variables_dict = self._eval_price_formula_variables_dict()
+ safe_eval(
+ price_formula,
+ globals_dict=variables_dict,
+ mode="exec",
+ nocopy=True,
+ )
+ price = variables_dict.get("price", 0)
+ else:
+ price = 0
+ return price
+
+ @api.depends(
+ "value",
+ )
+ def _compute_price(self):
+ for custom_value in self:
+ custom_value.price = custom_value._eval_price_formula()
def eval(self):
"""Return custom value evaluated using the related custom field type"""
diff --git a/product_configurator/tests/__init__.py b/product_configurator/tests/__init__.py
index 435b31b0a0..12c3808adc 100644
--- a/product_configurator/tests/__init__.py
+++ b/product_configurator/tests/__init__.py
@@ -7,3 +7,4 @@
# from . import test_product_attribute
# from . import test_product_config
# from . import test_wizard
+from . import test_custom_attribute_price
diff --git a/product_configurator/tests/test_custom_attribute_price.py b/product_configurator/tests/test_custom_attribute_price.py
new file mode 100644
index 0000000000..9398fa7389
--- /dev/null
+++ b/product_configurator/tests/test_custom_attribute_price.py
@@ -0,0 +1,112 @@
+# Copyright 2024 Simone Rubino - Aion Tech
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo.fields import first
+from odoo.tests import Form, TransactionCase
+from odoo.tools.safe_eval import safe_eval
+
+
+class TestCustomAttributePrice(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # The product attribute view only shows configuration fields
+ # (such as `val_custom`)
+ # when called with a specific context
+ # that is set by this action
+ configuration_attributes_action = cls.env.ref(
+ "product_configurator.action_attributes_view"
+ )
+ action_eval_context = configuration_attributes_action._get_eval_context()
+ configuration_attribute_context = safe_eval(
+ configuration_attributes_action.context, globals_dict=action_eval_context
+ )
+ configuration_attribute_model = cls.env["product.attribute"].with_context(
+ **configuration_attribute_context
+ )
+
+ custom_attribute_form = Form(configuration_attribute_model)
+ custom_attribute_form.name = "Test custom attribute"
+ custom_attribute_form.val_custom = True
+ cls.custom_attribute = custom_attribute_form.save()
+ cls.custom_attribute_value = cls.env.ref(
+ "product_configurator.custom_attribute_value"
+ )
+
+ regular_attribute_form = Form(configuration_attribute_model)
+ regular_attribute_form.name = "Test custom attribute"
+ regular_attribute_form.val_custom = False
+ with regular_attribute_form.value_ids.new() as value:
+ value.name = "Test value 1"
+ cls.regular_attribute = regular_attribute_form.save()
+
+ product_template_form = Form(cls.env["product.template"])
+ product_template_form.name = "Test configurable product"
+ with product_template_form.attribute_line_ids.new() as custom_line:
+ custom_line.attribute_id = cls.custom_attribute
+ with product_template_form.attribute_line_ids.new() as regular_line:
+ regular_line.attribute_id = cls.regular_attribute
+ regular_line.value_ids.add(first(cls.regular_attribute.value_ids))
+ product_template = product_template_form.save()
+ product_template.config_ok = True
+ cls.product_template = product_template
+
+ def test_integer_multiplier_formula(self):
+ """The custom attribute has a formula `custom_value` * `multiplier`,
+ check that the configuration's price is computed correctly.
+ """
+ # Arrange
+ regular_attribute = self.regular_attribute
+
+ multiplier = 5
+ custom_value = 3
+ custom_attribute = self.custom_attribute
+ custom_attribute.custom_type = "integer"
+ custom_attribute.configurator_extra_price_formula = (
+ "price = custom_value * %s" % multiplier
+ )
+ custom_attribute_value = self.custom_attribute_value
+
+ product_template = self.product_template
+
+ # Act: configure the product
+ wizard_action = product_template.configure_product()
+ wizard = self.env[wizard_action["res_model"]].browse(wizard_action["res_id"])
+ self.assertEqual(wizard.state, "select")
+ wizard.action_next_step()
+ self.assertEqual(wizard.state, "configure")
+ fields_prefixes = wizard._prefixes
+ field_prefix = fields_prefixes.get("field_prefix")
+ custom_field_prefix = fields_prefixes.get("custom_field_prefix")
+ wizard.write(
+ {
+ field_prefix
+ + str(regular_attribute.id): first(regular_attribute.value_ids).id,
+ field_prefix + str(custom_attribute.id): custom_attribute_value.id,
+ custom_field_prefix + str(custom_attribute.id): custom_value,
+ }
+ )
+ wizard.action_config_done()
+
+ # Assert
+ configured_session = wizard.config_session_id
+ configured_custom_value = configured_session.custom_value_ids
+ self.assertEqual(configured_custom_value.price, custom_value * multiplier)
+
+ expected_configuration_price = (
+ product_template.list_price + configured_custom_value.price
+ )
+ self.assertEqual(configured_session.price, expected_configuration_price)
+
+ # Act: change the custom value
+ new_custom_value = 2
+ configured_custom_value.value = "%s" % new_custom_value
+
+ # Assert: the price has changed
+ new_expected_custom_price = new_custom_value * multiplier
+ self.assertEqual(configured_custom_value.price, new_expected_custom_price)
+
+ new_expected_configuration_price = (
+ product_template.list_price + configured_custom_value.price
+ )
+ self.assertEqual(configured_session.price, new_expected_configuration_price)
diff --git a/product_configurator/views/product_attribute_view.xml b/product_configurator/views/product_attribute_view.xml
index 2316db3acc..d5c7409a79 100644
--- a/product_configurator/views/product_attribute_view.xml
+++ b/product_configurator/views/product_attribute_view.xml
@@ -82,6 +82,9 @@
+
+
+
diff --git a/product_configurator/views/product_config_view.xml b/product_configurator/views/product_config_view.xml
index 9a132f28fc..9e1a51113b 100644
--- a/product_configurator/views/product_config_view.xml
+++ b/product_configurator/views/product_config_view.xml
@@ -172,6 +172,7 @@
+