diff --git a/mrp_bom_line_formula_quantity/models/mrp_bom_line.py b/mrp_bom_line_formula_quantity/models/mrp_bom_line.py index ebb468a7803..acc928061cd 100644 --- a/mrp_bom_line_formula_quantity/models/mrp_bom_line.py +++ b/mrp_bom_line_formula_quantity/models/mrp_bom_line.py @@ -1,9 +1,11 @@ # Copyright 2024 Simone Rubino - Aion Tech # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import api, fields, models +import math + +from odoo import _, api, fields, models from odoo.exceptions import ValidationError -from odoo.tools.safe_eval import safe_eval, test_python_expr +from odoo.tools.safe_eval import safe_eval, test_python_expr, wrap_module class MRPBomLine(models.Model): @@ -29,9 +31,14 @@ class MRPBomLine(models.Model): "quantity_formula", ) def _constrain_quantity_formula(self): + # We also need to add all modules we use for validation in this list. + # This will be used in the _quantity_formula_values method. + # This will allow us to test the formula right away before saving it. + # This is a very robust way of validating the formula. for line in self: quantity_formula = line.quantity_formula if quantity_formula: + # First, check for syntax errors error_message = test_python_expr( expr=quantity_formula, mode="exec", @@ -39,6 +46,31 @@ def _constrain_quantity_formula(self): if error_message: raise ValidationError(error_message) + # Then, check for runtime errors by trying to evaluate the formula + # with a simple test case. We're using a try/except block here + # to catch any errors that occur during the evaluation. + try: + test_values = line._quantity_formula_values( + product=self.env["product.product"], + product_uom=self.env["uom.uom"], + product_uom_qty=1.0, + production=self.env["mrp.production"], + ) + safe_eval( + quantity_formula, + globals_dict=test_values, + mode="exec", + nocopy=True, + ) + except Exception as e: + raise ValidationError( + _( + "The formula is invalid. " + "A runtime error occurred during test evaluation: %s" + ) + % e + ) from e + def _quantity_formula_values( self, product, @@ -55,6 +87,7 @@ def _quantity_formula_values( "product_uom": product_uom, "product_uom_qty": product_uom_qty, "production": production, + "math": wrap_module(math, ["ceil", "floor"]), } def _eval_quantity_formula( diff --git a/mrp_bom_line_formula_quantity/tests/test_mrp_bom_line.py b/mrp_bom_line_formula_quantity/tests/test_mrp_bom_line.py index 8455701ab53..0b1c0625362 100644 --- a/mrp_bom_line_formula_quantity/tests/test_mrp_bom_line.py +++ b/mrp_bom_line_formula_quantity/tests/test_mrp_bom_line.py @@ -8,16 +8,41 @@ class TestMRPBoMLine(TestMrpCommon): - def test_formula_validation(self): - """The formula of a BoM line is checked for not permitted operations.""" + def test_formula_syntax_validation(self): + """The formula of a BoM line is checked for invalid Python syntax.""" # Arrange bom = self.bom_1.copy() bom_line = first(bom.bom_line_ids) - # Act + # Act & Assert for an invalid syntax with self.assertRaises(ValidationError) as ve: - bom_line["quantity_formula"] = "import *" - exc_message = ve.exception.args[0] + bom_line.quantity_formula = "import *" + self.assertIn("invalid syntax", ve.exception.args[0]) - # Assert - self.assertIn("invalid syntax", exc_message) + def test_formula_runtime_validation(self): + """The formula of a BoM line is checked for runtime errors.""" + # Arrange + bom = self.bom_1.copy() + bom_line = first(bom.bom_line_ids) + + # Act & Assert for an invalid function call (NameError) + with self.assertRaises(ValidationError) as ve: + bom_line.quantity_formula = ( + "quantity = math.nonexistent_function(product_uom_qty)" + ) + self.assertIn("runtime error", ve.exception.args[0]) + self.assertIn( + "object has no attribute 'nonexistent_function'", ve.exception.args[0] + ) + + def test_formula_valid(self): + """The formula of a BoM line is correctly validated for a valid function.""" + # Arrange + bom = self.bom_1.copy() + bom_line = first(bom.bom_line_ids) + + # Act & Assert for a valid formula with math.ceil() + try: + bom_line.quantity_formula = "quantity = math.ceil(product_uom_qty)" + except ValidationError: + self.fail("Valid formula should not raise ValidationError")