From 4e3e301c904a0bce42e4ac9abaeafd008b654ac6 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhodawala <99460106+Abdeali099@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:47:37 +0530 Subject: [PATCH] fix: formula evaluation and line reference validation for FRT (#59084) * fix: use one formula environment in validator and engine * test: cover shared formula environment * fix: use distinct dummy values when test-evaluating formulas * fix: reject line references that can't be used in a formula * fix: drop the undefined-reference check * fix: normalise line references before validating * fix: normalise formulas on save instead of during validation * fix: escape validation messages where they are rendered * fix: strip the formula in the engine instead of relying on the validator * fix: ignore division by zero when test-evaluating formulas --- .../financial_report_engine.py | 22 +-- .../financial_report_template.py | 7 + .../financial_report_validation.py | 97 +++++------ .../test_financial_report_template.py | 158 +++++++++++++++++- 4 files changed, 209 insertions(+), 75 deletions(-) diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py index a9759a73630..30c2b964138 100644 --- a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py @@ -3,7 +3,6 @@ import ast import json -import math from abc import ABC, abstractmethod from dataclasses import dataclass, field from functools import cache, reduce @@ -29,6 +28,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_templat FinancialReportTemplate, ) from erpnext.accounts.doctype.financial_report_template.financial_report_validation import ( + FORMULA_FUNCTIONS, AccountFilterValidator, CalculationFormulaValidator, DependencyValidator, @@ -1328,26 +1328,14 @@ class FormulaCalculator: self.precision = get_currency_precision() self.validator = CalculationFormulaValidator(set(row_data.keys())) - self.math_functions = { - "abs": abs, - "round": round, - "min": min, - "max": max, - "sum": sum, - "sqrt": math.sqrt, - "pow": math.pow, - "ceil": math.ceil, - "floor": math.floor, - } - def evaluate_formula(self, report_row: dict[str, Any]) -> list[float]: validation_result = self.validator.validate(report_row) - formula = report_row.calculation_formula + formula = (report_row.calculation_formula or "").strip() negation_factor = -1 if report_row.reverse_sign else 1 if validation_result.issues: # TODO: Throw? - messages = "

".join(issue.message for issue in validation_result.issues) + messages = "

".join(str(issue) for issue in validation_result.issues) frappe.log_error(f"Formula validation errors found:\n{messages}") return [0.0] * len(self.period_list) @@ -1362,7 +1350,7 @@ class FormulaCalculator: # TODO: consistent error handling try: context = self._build_context(period_index) - result = frappe.safe_eval(formula, context) + result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context) return flt(result * negation_factor, self.precision) except ZeroDivisionError: @@ -1383,7 +1371,7 @@ class FormulaCalculator: context[code] = 0.0 # math functions - context.update(self.math_functions) + context.update(FORMULA_FUNCTIONS) return context diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_template.py b/erpnext/accounts/doctype/financial_report_template/financial_report_template.py index b1a8ed05121..e7c29a0585e 100644 --- a/erpnext/accounts/doctype/financial_report_template/financial_report_template.py +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_template.py @@ -34,6 +34,13 @@ class FinancialReportTemplate(Document): def before_validate(self): self.clear_hidden_fields() + for row in self.rows: + if row.reference_code: + row.reference_code = row.reference_code.strip() + + if row.calculation_formula: + row.calculation_formula = row.calculation_formula.strip() + def clear_hidden_fields(self): style_data_sources = {"Blank Line", "Column Break", "Section Break"} diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_validation.py b/erpnext/accounts/doctype/financial_report_template/financial_report_validation.py index 3f3000f33cf..439537c0e41 100644 --- a/erpnext/accounts/doctype/financial_report_template/financial_report_validation.py +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_validation.py @@ -2,6 +2,8 @@ # For license information, please see license.txt import json +import keyword +import math import re from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -10,6 +12,19 @@ from typing import Any import frappe from frappe import _, is_whitelisted from frappe.database.operator_map import OPERATOR_MAP +from frappe.utils import escape_html + +FORMULA_FUNCTIONS = { + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, + "sqrt": math.sqrt, + "pow": math.pow, + "ceil": math.ceil, + "floor": math.floor, +} def get_valid_api_method(api_path: str): @@ -89,8 +104,9 @@ class ValidationResult: self.warnings.append(issue) def notify_user(self) -> None: - warnings = "

".join(str(w) for w in self.warnings if w) - errors = "

".join(str(e) for e in self.issues if e) + # messages quote user input back, and both are rendered as HTML + warnings = "

".join(escape_html(str(w)) for w in self.warnings if w) + errors = "

".join(escape_html(str(e)) for e in self.issues if e) if warnings: frappe.msgprint(warnings, title=_("Warnings"), indicator="orange") @@ -147,18 +163,27 @@ class TemplateStructureValidator(Validator): if not row.reference_code: continue - ref_code = row.reference_code.strip() + ref_code = row.reference_code - # Check format - if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code): + # a line reference is used as a name in formulas, so it must be a usable one + if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", ref_code): result.add_error( ValidationIssue( message=_( - "Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens" + "Invalid line reference format: '{0}'. Must start with a letter and contain only letters, numbers and underscores" ).format(ref_code), row_idx=row.idx, ) ) + elif keyword.iskeyword(ref_code) or ref_code in FORMULA_FUNCTIONS: + result.add_error( + ValidationIssue( + message=_("'{0}' is a reserved name and cannot be used as a line reference").format( + ref_code + ), + row_idx=row.idx, + ) + ) # Check uniqueness if ref_code in used_codes: @@ -208,12 +233,7 @@ class DependencyValidator(Validator): self.dependencies = self._build_dependency_graph() def validate(self, context=None) -> ValidationResult: - result = ValidationResult() - - result.merge(self._validate_circular_dependencies()) - result.merge(self._validate_missing_dependencies()) - - return result + return self._validate_circular_dependencies() def _build_dependency_graph(self) -> dict[str, list[str]]: graph = {} @@ -280,31 +300,6 @@ class DependencyValidator(Validator): return result - def _validate_missing_dependencies(self) -> ValidationResult: - available = {row.reference_code for row in self.template.rows if row.reference_code} - result = ValidationResult() - - for ref_code, deps in self.dependencies.items(): - undefined = [d for d in deps if d not in available] - if undefined: - row_idx = self._get_row_idx(ref_code) - result.add_error( - ValidationIssue( - message=_("Line references undefined in {0}: {1}").format( - get_formula_field_label("Calculated Amount"), ", ".join(undefined) - ), - row_idx=row_idx, - ) - ) - - return result - - def _get_row_idx(self, reference_code: str) -> int | None: - for row in self.template.rows: - if row.reference_code == reference_code: - return row.idx - return None - class CalculationFormulaValidator(Validator): """Validates calculation formulas used in Calculated Amount rows""" @@ -320,7 +315,6 @@ class CalculationFormulaValidator(Validator): return result formula = self._preprocess_formula(row.calculation_formula) - row.calculation_formula = formula # Check parentheses if not self._are_parentheses_balanced(formula): @@ -368,25 +362,15 @@ class CalculationFormulaValidator(Validator): def _test_formula_evaluation(self, formula: str, available_codes: list[str]) -> str | None: try: context = {code: 1.0 for code in available_codes} - context.update( - { - "abs": abs, - "round": round, - "min": min, - "max": max, - "sum": sum, - "sqrt": lambda x: x**0.5, - "pow": pow, - "ceil": lambda x: int(x) + (1 if x % 1 else 0), - "floor": int, - } - ) + context.update(FORMULA_FUNCTIONS) result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context) - if not isinstance(result, (int, float)): # noqa: UP038 + if not isinstance(result, (int | float)): return _("Formula must return a numeric value, got {0}").format(type(result).__name__) + return None + except ZeroDivisionError: return None except Exception as e: return str(e) @@ -462,13 +446,14 @@ class AccountFilterValidator(Validator): return _("Field and operator must be strings") if field not in account_fields: - # escape: `field` is caller-supplied and this message renders as HTML - return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field)) + return _("Field '{0}' is not a valid Account field").format(field) - if operator.casefold() not in OPERATOR_MAP: + normalized_operator = operator.casefold() + + if normalized_operator not in OPERATOR_MAP: return _("Invalid operator '{0}'").format(operator) - if operator in ["in", "not in"] and not isinstance(value, list): + if normalized_operator in ["in", "not in"] and not isinstance(value, list): return _("Operator '{0}' requires a list value").format(operator) # logical condition: {"and": [condition1, condition2]} diff --git a/erpnext/accounts/doctype/financial_report_template/test_financial_report_template.py b/erpnext/accounts/doctype/financial_report_template/test_financial_report_template.py index 7b23398a472..24e8403b99a 100644 --- a/erpnext/accounts/doctype/financial_report_template/test_financial_report_template.py +++ b/erpnext/accounts/doctype/financial_report_template/test_financial_report_template.py @@ -5,8 +5,11 @@ import frappe from frappe.tests.utils import whitelist_for_tests from erpnext.accounts.doctype.financial_report_template.financial_report_validation import ( + FORMULA_FUNCTIONS, AccountFilterValidator, + CalculationFormulaValidator, FormulaValidator, + TemplateStructureValidator, get_valid_api_method, ) from erpnext.tests.utils import ERPNextTestSuite @@ -189,8 +192,13 @@ class TestAccountFilter(FinancialReportTemplateTestCase): def test_error_message_labels_and_escapes_field(self): validator = AccountFilterValidator() result = validator.validate_filter(self._row('["