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