# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import re from odoo import api, fields, models, _ from odoo.exceptions import ValidationError from odoo.tools.safe_eval import safe_eval REGEX_FORMULA_OBJECT = re.compile(r'((?:product\[\')(?P\w+)(?:\'\]))+') FORMULA_ALLOWED_TOKENS = { '(', ')', '+', '-', '*', '/', ',', '<', '>', '<=', '>=', 'and', 'or', 'None', 'base', 'quantity', 'price_unit', 'min', 'max', } class AccountTaxPython(models.Model): _inherit = "account.tax" amount_type = fields.Selection( selection_add=[('code', "Custom Formula")], ondelete={'code': lambda recs: recs.write({'amount_type': 'percent', 'active': False})}, ) formula = fields.Text( string="Formula", default="price_unit * 0.10", help="Compute the amount of the tax.\n\n" ":param base: float, actual amount on which the tax is applied\n" ":param price_unit: float\n" ":param quantity: float\n" ":param product: A object representing the product\n" ) formula_decoded_info = fields.Json(compute='_compute_formula_decoded_info') @api.constrains('amount_type', 'formula') def _check_amount_type_code_formula(self): for tax in self: if tax.amount_type == 'code': tax._check_formula() @api.model def _eval_taxes_computation_prepare_product_fields(self): # EXTENDS 'account' field_names = super()._eval_taxes_computation_prepare_product_fields() for tax in self.filtered(lambda tax: tax.amount_type == 'code'): field_names.update(tax.formula_decoded_info['product_fields']) return field_names @api.depends('formula') def _compute_formula_decoded_info(self): for tax in self: if tax.amount_type != 'code': tax.formula_decoded_info = None continue formula = (tax.formula or '0.0').strip() formula_decoded_info = { 'js_formula': formula, 'py_formula': formula, } product_fields = set() groups = re.findall(r'((?:product\.)(?P\w+))+', formula) or [] Product = self.env['product.product'] for group in groups: field_name = group[1] if field_name in Product and not Product._fields[field_name].relational: product_fields.add(field_name) formula_decoded_info['py_formula'] = formula_decoded_info['py_formula'].replace(f"product.{field_name}", f"product['{field_name}']") formula_decoded_info['product_fields'] = list(product_fields) tax.formula_decoded_info = formula_decoded_info def _check_formula(self): """ Check the formula is passing the minimum check to ensure the compatibility between both evaluation in python & javascript. """ self.ensure_one() def get_number_size(formula, i): starting_i = i seen_separator = False while i < len(formula): if formula[i].isnumeric(): i += 1 elif formula[i] == '.' and (i - starting_i) > 0 and not seen_separator: i += 1 seen_separator = True else: break return i - starting_i formula_decoded_info = self.formula_decoded_info allowed_tokens = FORMULA_ALLOWED_TOKENS.union(f"product['{field_name}']" for field_name in formula_decoded_info['product_fields']) formula = formula_decoded_info['py_formula'] i = 0 while i < len(formula): if formula[i] == ' ': i += 1 continue continue_needed = False for token in allowed_tokens: if formula[i:i + len(token)] == token: i += len(token) continue_needed = True break if continue_needed: continue number_size = get_number_size(formula, i) if number_size > 0: i += number_size continue raise ValidationError(_("Malformed formula '%(formula)s' at position %(position)s", formula=formula, position=i)) @api.model def _eval_tax_amount_formula(self, raw_base, evaluation_context): """ Evaluate the formula of the tax passed as parameter. [!] Mirror of the same method in account_tax.js. PLZ KEEP BOTH METHODS CONSISTENT WITH EACH OTHERS. :param tax_data: The values of a tax returned by '_prepare_taxes_computation'. :param evaluation_context: The context created by '_eval_taxes_computation_prepare_context'. :return: The tax base amount. """ self._check_formula() # Safe eval. formula_context = { 'price_unit': evaluation_context['price_unit'], 'quantity': evaluation_context['quantity'], 'product': evaluation_context['product'], 'base': raw_base, 'min': min, 'max': max, } try: return safe_eval( self.formula_decoded_info['py_formula'], globals_dict=formula_context, locals_dict={}, locals_builtins=False, nocopy=True, ) except ZeroDivisionError: return 0.0 def _eval_tax_amount_fixed_amount(self, batch, raw_base, evaluation_context): # EXTENDS 'account' if self.amount_type == 'code': return self._eval_tax_amount_formula(raw_base, evaluation_context) return super()._eval_tax_amount_fixed_amount(batch, raw_base, evaluation_context)