Skip to content

Commit

Permalink
Merge pull request #26 from yoyowallet/add-default-value-for-action-p…
Browse files Browse the repository at this point in the history
…aram

Add default value for action param
  • Loading branch information
deniskolosov authored Apr 8, 2019
2 parents ffefc86 + 4c96bdd commit f827cb5
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 34 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion business_rules/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
34 changes: 28 additions & 6 deletions business_rules/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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_)
Expand All @@ -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
35 changes: 31 additions & 4 deletions business_rules/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand All @@ -184,20 +184,47 @@ 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)

if not method:
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
Expand Down
1 change: 1 addition & 0 deletions business_rules/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
FIELD_SELECT_MULTIPLE = 'select_multiple'
FIELD_DATETIME = 'datetime'
FIELD_TIME = 'time'
FIELD_BOOLEAN = 'boolean'
36 changes: 35 additions & 1 deletion business_rules/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__))
Expand All @@ -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):
"""
Expand Down
6 changes: 3 additions & 3 deletions tests/test_actions_class.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down
130 changes: 114 additions & 16 deletions tests/test_engine_logic.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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):
Expand Down
Loading

0 comments on commit f827cb5

Please sign in to comment.