diff --git a/README.md b/README.md index cf0fd64d..40369281 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,26 @@ class ProductActions(BaseActions): self.product.save() ``` +If you introduced a new action parameter but already have an older set of active Rules, +which will all require to be updated with this new parameter, you can use `ActionParam` class which +contains default value for parameter along with field type: +```python +from business_rules.actions import ActionParam, BaseActions, rule_action +from business_rules import fields + +class ProductActions(BaseActions): + + def __init__(self, product): + self.product = product + + @rule_action(params={"sale_percentage": fields.FIELD_NUMERIC, + "on_sale": ActionParam(field_type=fields.FIELD_BOOLEAN, default_value=False}) + def put_on_sale(self, sale_percentage, on_sale): + self.product.price = (1.0 - sale_percentage) * self.product.price + self.on_sale = on_sale + self.product.save() + +``` ### 3. Build the rules A rule is just a JSON object that gets interpreted by the business-rules engine. diff --git a/business_rules/__init__.py b/business_rules/__init__.py index 54e87a48..e64d144d 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.4.2' +__version__ = '1.5.0' from .engine import run_all, check_conditions_recursively from .utils import export_rule_data, validate_rule_data diff --git a/business_rules/actions.py b/business_rules/actions.py index 4f0b6aa7..647269b0 100644 --- a/business_rules/actions.py +++ b/business_rules/actions.py @@ -25,6 +25,12 @@ def _validate_action_parameters(func, params): function `func`, and that the field types are FIELD_* types in fields. :param func: :param params: + { + 'label': 'action_label', + 'name': 'action_parameter', + 'fieldType': 'numeric', + 'defaultValue': 123 + } :return: """ if params is not None: @@ -45,8 +51,17 @@ def _validate_action_parameters(func, params): def rule_action(label=None, params=None): """ Decorator to make a function into a rule action. - - NOTE: add **kwargs argument to receive Rule and Matched Conditions as parameters in Action function + `params` parameter could be one of the following: + 1. Dictionary with params names as keys and types as values + Example: + params={ + 'param_name': fields.FIELD_NUMERIC, + } + + 2. If a param has a default value, ActionParam can be used. Example: + params={ + 'action_parameter': ActionParam(field_type=fields.FIELD_NUMERIC, default_value=123) + } :param label: Label for Action :param params: Parameters expected by the Action function @@ -58,10 +73,11 @@ def wrapper(func): if isinstance(params, dict): params_ = [ dict( - label=fn_name_to_pretty_label(name), - name=name, - fieldType=field_type, - ) for name, field_type in params.items() + label=fn_name_to_pretty_label(key), + name=key, + fieldType=getattr(value, "field_type", value), + defaultValue=getattr(value, "default_value", None) + ) for key, value in params.items() ] _validate_action_parameters(func, params_) @@ -73,3 +89,9 @@ def wrapper(func): return func return wrapper + + +class ActionParam: + def __init__(self, field_type, default_value=None): + self.field_type = field_type + self.default_value = default_value diff --git a/business_rules/engine.py b/business_rules/engine.py index 9b4c4810..dc82c60a 100644 --- a/business_rules/engine.py +++ b/business_rules/engine.py @@ -175,7 +175,7 @@ def do_actions(actions, defined_actions, checked_conditions_results, rule): :param defined_actions: Class with function that implement the logic for each possible action defined in 'actions' parameter :param checked_conditions_results: - :param rule: Rule that is beign executed + :param rule: Rule that is being executed :return: None """ @@ -184,7 +184,7 @@ def do_actions(actions, defined_actions, checked_conditions_results, rule): for action in actions: method_name = action['name'] - params = action.get('params', {}) + action_params = action.get('params', {}) method = getattr(defined_actions, method_name, None) @@ -192,12 +192,39 @@ def do_actions(actions, defined_actions, checked_conditions_results, rule): raise AssertionError( "Action {0} is not defined in class {1}".format(method_name, defined_actions.__class__.__name__)) - utils.check_params_valid_for_method(method, params, method_type.METHOD_TYPE_ACTION) + missing_params_with_default_value = utils.check_params_valid_for_method(method, action_params, + method_type.METHOD_TYPE_ACTION) - method_params = _build_action_parameters(method, params, rule, successful_conditions) + if missing_params_with_default_value: + action_params = _set_default_values_for_missing_action_params(method, + missing_params_with_default_value, + action_params) + + method_params = _build_action_parameters(method, action_params, rule, successful_conditions) method(**method_params) +def _set_default_values_for_missing_action_params(method, missing_parameters_with_default_value, action_params): + """ + Adds default parameter from method params to Action parameters. + :param method: Action object. + :param parameters_with_default_value: set of parameters which have a default value for Action parameters. + :param action_params: Action parameters dict. + :return: Modified action_params. + """ + modified_action_params = {} + if getattr(method, 'params', None): + for param in method.params: + param_name = param['name'] + if param_name in missing_parameters_with_default_value: + default_value = param.get('defaultValue', None) + if default_value is not None: + modified_action_params[param_name] = default_value + continue + modified_action_params[param_name] = action_params[param_name] + return modified_action_params + + def _build_action_parameters(method, parameters, rule, conditions): """ Adds extra parameters to the parameters defined for the method diff --git a/business_rules/fields.py b/business_rules/fields.py index cc3c8af8..2d41d8e5 100644 --- a/business_rules/fields.py +++ b/business_rules/fields.py @@ -5,3 +5,4 @@ FIELD_SELECT_MULTIPLE = 'select_multiple' FIELD_DATETIME = 'datetime' FIELD_TIME = 'time' +FIELD_BOOLEAN = 'boolean' diff --git a/business_rules/utils.py b/business_rules/utils.py index 0cbefbb7..e18eb385 100644 --- a/business_rules/utils.py +++ b/business_rules/utils.py @@ -103,12 +103,21 @@ def check_params_valid_for_method(method, given_params, method_type_name): :param method: :param given_params: Parameters defined within the Rule (Action or Condition) :param method_type_name: A method type defined in util.method_type module - :return: None. Raise exception if parameters don't match (defined in method and Rule) + :return: Set of default values for params which are missing but have a default value. Raise exception if parameters + don't + match (defined in method and + Rule) """ method_params = params_dict_to_list(method.params) defined_params = [param.get('name') for param in method_params] missing_params = set(defined_params).difference(given_params) + # check for default value in action parameters, if it is present, exclude param from missing params + params_with_default_value = set() + if method_type_name == method_type.METHOD_TYPE_ACTION and missing_params: + params_with_default_value = check_for_default_value_for_missing_params(missing_params, method_params) + missing_params -= params_with_default_value + if missing_params: raise AssertionError("Missing parameters {0} for {1} {2}".format( ', '.join(missing_params), method_type_name, method.__name__)) @@ -119,6 +128,31 @@ def check_params_valid_for_method(method, given_params, method_type_name): raise AssertionError("Invalid parameters {0} for {1} {2}".format( ', '.join(invalid_params), method_type_name, method.__name__)) + return params_with_default_value + + +def check_for_default_value_for_missing_params(missing_params, method_params): + """ + :param missing_params: Params missing from Rule + :param method_params: Params defined on method, which could have default value for missing param + [{ + 'label': 'action_label', + 'name': 'action_parameter', + 'fieldType': 'numeric', + 'defaultValue': 123 + }, + ... + ] + :return Params that are missing from rule but have default params: {'action_parameter'} + """ + missing_params_with_default_value = set() + if method_params: + for param in method_params: + if param['name'] in missing_params and param.get('defaultValue', None) is not None: + missing_params_with_default_value.add(param['name']) + + return missing_params_with_default_value + def validate_rule_data(variables, actions, rule): """ diff --git a/tests/test_actions_class.py b/tests/test_actions_class.py index d191e408..a7a52941 100644 --- a/tests/test_actions_class.py +++ b/tests/test_actions_class.py @@ -1,6 +1,6 @@ from __future__ import absolute_import -from business_rules.actions import BaseActions, rule_action -from business_rules.fields import FIELD_TEXT +from business_rules.actions import BaseActions, rule_action, ActionParam +from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC from . import TestCase @@ -29,7 +29,7 @@ def non_action(self): self.assertEqual(actions[0]['name'], 'some_action') self.assertEqual(actions[0]['label'], 'Some Action') self.assertEqual(actions[0]['params'], [ - {'fieldType': FIELD_TEXT, 'name': 'foo', 'label': 'Foo'}, + {'fieldType': FIELD_TEXT, 'name': 'foo', 'label': 'Foo', 'defaultValue': None}, ]) # should work on an instance of the class too diff --git a/tests/test_engine_logic.py b/tests/test_engine_logic.py index 2ee8d6f7..627dc423 100644 --- a/tests/test_engine_logic.py +++ b/tests/test_engine_logic.py @@ -1,9 +1,10 @@ from __future__ import absolute_import -from mock import patch, MagicMock +from mock import patch, MagicMock from business_rules import engine from business_rules import fields -from business_rules.actions import BaseActions +from business_rules.actions import BaseActions, ActionParam, rule_action +from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC from business_rules.models import ConditionResult from business_rules.operators import StringType from business_rules.variables import BaseVariables @@ -214,21 +215,26 @@ def test_do_actions(self): 'actions': rule_actions } - defined_actions = BaseActions() + action1_mock = MagicMock() + action2_mock = MagicMock() - defined_actions.action1 = MagicMock() - defined_actions.action2 = MagicMock() - defined_actions.action2.params = { - 'param1': fields.FIELD_TEXT, - 'param2': fields.FIELD_NUMERIC - } + class SomeActions(BaseActions): + @rule_action() + def action1(self): + return action1_mock() + + @rule_action(params={'param1': FIELD_TEXT, "param2": FIELD_NUMERIC}) + def action2(self, param1, param2): + return action2_mock(param1=param1, param2=param2) + + defined_actions = SomeActions() payload = [(True, 'condition_name', 'operator_name', 'condition_value')] engine.do_actions(rule_actions, defined_actions, payload, rule) - defined_actions.action1.assert_called_once_with() - defined_actions.action2.assert_called_once_with(param1='foo', param2=10) + action1_mock.assert_called_once_with() + action2_mock.assert_called_once_with(param1='foo', param2=10) def test_do_actions_with_injected_parameters(self): function_params_mock = MagicMock() @@ -253,12 +259,21 @@ def test_do_actions_with_injected_parameters(self): defined_actions = BaseActions() defined_actions.action1 = MagicMock() + defined_actions.action1.params = [] defined_actions.action2 = MagicMock() - defined_actions.action2.params = { - 'param1': fields.FIELD_TEXT, - 'param2': fields.FIELD_NUMERIC - } - + defined_actions.action2.params = [{ + 'label': 'action2', + 'name': 'param1', + 'fieldType': fields.FIELD_TEXT, + 'defaultValue': None + }, + { + 'label': 'action2', + 'name': 'param2', + 'fieldType': fields.FIELD_NUMERIC, + 'defaultValue': None + } + ] payload = [(True, 'condition_name', 'operator_name', 'condition_value')] engine.do_actions(rule_actions, defined_actions, payload, rule) @@ -280,6 +295,89 @@ def test_do_with_invalid_action(self): with self.assertRaisesRegexp(AssertionError, err_string): engine.do_actions(actions, BaseActions(), checked_conditions_results, rule) + def test_do_with_parameter_with_default_value(self): + function_params_mock = MagicMock() + function_params_mock.varkw = None + with patch('business_rules.engine.getfullargspec', return_value=function_params_mock): + # param2 is not set in rule, but there is a default parameter for it in action which will be used instead + rule_actions = [ + { + 'name': 'some_action', + 'params': {'param1': 'foo'} + } + ] + + rule = { + 'conditions': { + + }, + 'actions': rule_actions + } + + action_param_with_default_value = ActionParam(field_type=fields.FIELD_NUMERIC, default_value=42) + + action_mock = MagicMock() + + class SomeActions(BaseActions): + @rule_action(params={'param1': FIELD_TEXT, 'param2': action_param_with_default_value}) + def some_action(self, param1, param2): + return action_mock(param1=param1, param2=param2) + + defined_actions = SomeActions() + + defined_actions.action = MagicMock() + defined_actions.action.params = { + 'param1': fields.FIELD_TEXT, + 'param2': action_param_with_default_value + } + + payload = [(True, 'condition_name', 'operator_name', 'condition_value')] + + engine.do_actions(rule_actions, defined_actions, payload, rule) + + action_mock.assert_called_once_with(param1='foo', param2=42) + + def test_default_param_overrides_action_param(self): + + function_params_mock = MagicMock() + function_params_mock.varkw = None + with patch('business_rules.engine.getfullargspec', return_value=function_params_mock): + rule_actions = [ + { + 'name': 'some_action', + 'params': {'param1': False} + } + ] + + rule = { + 'conditions': { + + }, + 'actions': rule_actions + } + + action_param_with_default_value = ActionParam(field_type=fields.FIELD_TEXT, default_value='bar') + + action_mock = MagicMock() + + class SomeActions(BaseActions): + @rule_action(params={'param1': action_param_with_default_value}) + def some_action(self, param1): + return action_mock(param1=param1) + + defined_actions = SomeActions() + + defined_actions.action = MagicMock() + defined_actions.action.params = { + 'param1': action_param_with_default_value, + } + + payload = [(True, 'condition_name', 'operator_name', 'condition_value')] + + engine.do_actions(rule_actions, defined_actions, payload, rule) + + action_mock.assert_called_once_with(param1=False) + class EngineCheckConditionsTests(TestCase): def test_case1(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index 2228e7ea..5cdddefa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -37,7 +37,7 @@ def test_fn_name_to_pretty_label_with_different_cases(): def test_get_valid_fields(): valid_fields = utils.get_valid_fields() - assert len(valid_fields) == 7 + assert len(valid_fields) == 8 def test_params_dict_to_list_when_params_none(): @@ -65,7 +65,8 @@ def test_export_rule_data(): { 'fieldType': 'numeric', 'label': 'Foo', - 'name': 'foo' + 'name': 'foo', + 'defaultValue': None } ] }, @@ -76,7 +77,8 @@ def test_export_rule_data(): { 'fieldType': 'text', 'label': 'Bar', - 'name': 'bar' + 'name': 'bar', + 'defaultValue': None } ] },