Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Return Fired rules actions results. #34 #93

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
History
=========

## 1.1.2

Release date: 2023-6-25

- Added support for returning the results for applied actions of triggered rules

## 1.1.1

Release date: 2022-3-18
Expand Down
115 changes: 70 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class ProductVariables(BaseVariables):

### 2. Define your set of actions

These are the actions that are available to be taken when a condition is triggered.
These are the actions that are available to be taken when a condition is triggered. Actions can either persist the changes directly, or can return an updated state back for any downstream use cases, or do both.

For example:

Expand All @@ -62,6 +62,7 @@ class ProductActions(BaseActions):
def put_on_sale(self, sale_percentage):
self.product.price = (1.0 - sale_percentage) * self.product.price
self.product.save()
return self.product.price # optionally return some state

@rule_action(params={"number_to_order": FIELD_NUMERIC})
def order_more(self, number_to_order):
Expand Down Expand Up @@ -100,48 +101,59 @@ An example of the resulting python lists/dicts is:

```python
rules = [
# expiration_days < 5 AND current_inventory > 20
{ "conditions": { "all": [
{ "name": "expiration_days",
"operator": "less_than",
"value": 5,
},
{ "name": "current_inventory",
"operator": "greater_than",
"value": 20,
},
]},
"actions": [
{ "name": "put_on_sale",
"params": {"sale_percentage": 0.25},
},
],
},

# current_inventory < 5 OR (current_month = "December" AND current_inventory < 20)
{ "conditions": { "any": [
{ "name": "current_inventory",
"operator": "less_than",
"value": 5,
},
]},
{ "all": [
{ "name": "current_month",
"operator": "equal_to",
"value": "December",
# expiration_days < 5 AND current_inventory > 20
{
"name": "Rule for Putting Product on Sale", # name is optional
"conditions": {
"all": [
{
"name": "expiration_days",
"operator": "less_than",
"value": 5,
},
{
"name": "current_inventory",
"operator": "greater_than",
"value": 20,
},
]
},
{ "name": "current_inventory",
"operator": "less_than",
"value": 20,
}
]},
},
"actions": [
{ "name": "order_more",
"params":{"number_to_order": 40},
"actions": [
{"name": "put_on_sale", "params": {"sale_percentage": 0.25}},
],
},
],
}]

# current_inventory < 5 OR (current_month = "December" AND current_inventory < 20)
{
"name": "Rule for restocking", # name is optional
"conditions": {
"any": [
{
"name": "current_inventory",
"operator": "less_than",
"value": 5,
},
{
"all": [
{
"name": "current_month",
"operator": "equal_to",
"value": "December",
},
{
"name": "current_inventory",
"operator": "less_than",
"value": 20,
}
]
}
],
"actions": [
{"name": "order_more", "params": {"number_to_order": 40}},
],
}
}
]
```

### Export the available variables, operators and actions
Expand Down Expand Up @@ -213,6 +225,23 @@ for product in Products.objects.all():
)
```

Alternatively, `run_all_with_results` can be used to get the updated state for applied actions of triggered rules.

```python
from business_rules import run_all_with_results

rules = _some_function_to_receive_from_client()

for product in Products.objects.all():
rules_action_results = run_all_with_results(
rule_list=rules,
defined_variables=ProductVariables(product),
defined_actions=ProductActions(product),
stop_on_first_trigger=True
)

```

## API

#### Variable Types and Decorators:
Expand Down Expand Up @@ -271,10 +300,6 @@ Note: to compare floating point equality we just check that the difference is le
* `shares_exactly_one_element_with`
* `shares_no_elements_with`

### Returning data to your client



## Contributing

Open up a pull request, making sure to add tests for any new functionality. To set up the dev environment (assuming you're using [virtualenvwrapper](http://docs.python-guide.org/en/latest/dev/virtualenvs/#virtualenvwrapper)):
Expand Down
2 changes: 2 additions & 0 deletions business_rules/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
__version__ = '1.1.1'

from .engine import run_all
from .engine import run_all_with_results
from .utils import export_rule_data

# Appease pyflakes by "using" these exports
assert run_all
assert run_all_with_results
assert export_rule_data
70 changes: 65 additions & 5 deletions business_rules/engine.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from .fields import FIELD_NO_INPUT


def run_all(rule_list,
defined_variables,
defined_actions,
stop_on_first_trigger=False):

rule_was_triggered = False
for rule in rule_list:
result = run(rule, defined_variables, defined_actions)
Expand All @@ -14,6 +14,42 @@ def run_all(rule_list,
return True
return rule_was_triggered


def run_all_with_results(rule_list, defined_variables, defined_actions, stop_on_first_trigger=False):
"""
Runs all the rules and returns the results returned by actions of triggered rule(s).

Returns:
rule_results(dict): dictionary with results of actions for every triggered rule. {} if no rule triggered.
Uses rule's `name` as key if available, otherwise rule's index is used as key.
"""
rule_results = {}
for ix, rule in enumerate(rule_list):
triggered, actions_results = run_and_get_results(rule, defined_variables,
defined_actions)
if triggered:
rule_name = rule.get('name', ix)
rule_results[rule_name] = actions_results
if stop_on_first_trigger:
return rule_results
return rule_results


def run_and_get_results(rule, defined_variables, defined_actions):
"""Run the rule and get the action returned result
Attributes:
rule(dict): the rule dictionary
defined_variables(BaseVariables): the defined set of variables object
defined_actions(BaseActions): the actions object
"""
actions_results = None
conditions, actions = rule.get('conditions'), rule.get('actions')
rule_triggered = check_conditions_recursively(conditions, defined_variables)
if rule_triggered:
actions_results = do_actions(actions, defined_actions)
return rule_triggered, actions_results


def run(rule, defined_variables, defined_actions):
conditions, actions = rule['conditions'], rule['actions']
rule_triggered = check_conditions_recursively(conditions, defined_variables)
Expand Down Expand Up @@ -45,6 +81,7 @@ def check_conditions_recursively(conditions, defined_variables):
assert not ('any' in keys or 'all' in keys)
return check_condition(conditions, defined_variables)


def check_condition(condition, defined_variables):
""" Checks a single rule condition - the condition will be made up of
variables, values, and the comparison operator. The defined_variables
Expand All @@ -54,20 +91,24 @@ def check_condition(condition, defined_variables):
operator_type = _get_variable_value(defined_variables, name)
return _do_operator_comparison(operator_type, op, value)


def _get_variable_value(defined_variables, name):
""" Call the function provided on the defined_variables object with the
given name (raise exception if that doesn't exist) and casts it to the
specified type.

Returns an instance of operators.BaseType
"""

def fallback(*args, **kwargs):
raise AssertionError("Variable {0} is not defined in class {1}".format(
name, defined_variables.__class__.__name__))
name, defined_variables.__class__.__name__))

method = getattr(defined_variables, name, fallback)
val = method()
return method.field_type(val)


def _do_operator_comparison(operator_type, operator_name, comparison_value):
""" Finds the method on the given operator_type and compares it to the
given comparison_value.
Expand All @@ -76,21 +117,40 @@ def _do_operator_comparison(operator_type, operator_name, comparison_value):
comparison_value is whatever python type to compare to
returns a bool
"""

def fallback(*args, **kwargs):
raise AssertionError("Operator {0} does not exist for type {1}".format(
operator_name, operator_type.__class__.__name__))

method = getattr(operator_type, operator_name, fallback)
if getattr(method, 'input_type', '') == FIELD_NO_INPUT:
return method()
return method(comparison_value)


def do_actions(actions, defined_actions):
""" Run the actions
Attributes:
actions(list): list of dictionaries of actions. e.g: [
{ "name": "put_on_sale",
"params": {"sale_percentage": 0.25},
}
]
Returns:
actionsResults(dict): Dictionary of actions results
e.g: {"put_on_sale: [product1, product2, ...]}
"""
actions_results = {}
for action in actions:
method_name = action['name']

def fallback(*args, **kwargs):
raise AssertionError("Action {0} is not defined in class {1}"\
.format(method_name, defined_actions.__class__.__name__))
raise AssertionError(
"Action {0} is not defined in class {1}".format(method_name,
defined_actions.__class__.__name__))

params = action.get('params') or {}
method = getattr(defined_actions, method_name, fallback)
method(**params)
actions_results[method_name] = method(**params)

return actions_results
45 changes: 43 additions & 2 deletions tests/test_engine_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,53 @@ def test_run_all_stop_on_first(self, *args):
actions = BaseActions()

result = engine.run_all([rule1, rule2], variables, actions,
stop_on_first_trigger=True)
stop_on_first_trigger=True)
self.assertEqual(result, True)
self.assertEqual(engine.run.call_count, 1)
engine.run.assert_called_once_with(rule1, variables, actions)

@patch.object(engine, 'run_and_get_results')
def test_run_all_with_results_some_rule_triggered(self, *args):
""" By default, does not stop on first triggered rule. Returns True if
any rule was triggered, otherwise False
"""
rule1 = {'name': 'rule 1', 'conditions': 'condition1', 'actions': 'action name 1'}
rule2 = {'name': 'rule 2', 'conditions': 'condition2', 'actions': 'action name 2'}
variables = BaseVariables()
actions = BaseActions()

def return_action1(rule, *args, **kwargs):
if rule['name'] == 'rule 1':
return True, {'action name 1': None}
return False, None

engine.run_and_get_results.side_effect = return_action1

result = engine.run_all_with_results([rule1, rule2], variables, actions)

self.assertDictEqual(result, {'rule 1': {'action name 1': None}})
self.assertEqual(engine.run_and_get_results.call_count, 2)

# switch order and try again
engine.run_and_get_results.reset_mock()

result = engine.run_all_with_results([rule2, rule1], variables, actions)
self.assertDictEqual(result, {'rule 1': {'action name 1': None}})
self.assertEqual(engine.run_and_get_results.call_count, 2)

@patch.object(engine, 'run_and_get_results', return_value=(True, {'action name 1': None}))
def test_run_all_with_results_stop_on_first(self, *args):
rule1 = {'name': 'rule 1', 'conditions': 'condition1', 'actions': 'action name 1'}
rule2 = {'name': 'rule 2', 'conditions': 'condition2', 'actions': 'action name 2'}
variables = BaseVariables()
actions = BaseActions()

result = engine.run_all_with_results([rule1, rule2], variables, actions,
stop_on_first_trigger=True)
self.assertDictEqual(result, {'rule 1': {'action name 1': None}})
self.assertEqual(engine.run_and_get_results.call_count, 1)
engine.run_and_get_results.assert_called_once_with(rule1, variables, actions)

@patch.object(engine, 'check_conditions_recursively', return_value=True)
@patch.object(engine, 'do_actions')
def test_run_that_triggers_rule(self, *args):
Expand Down Expand Up @@ -78,7 +120,6 @@ def test_run_that_doesnt_trigger_rule(self, *args):
rule['conditions'], variables)
self.assertEqual(engine.do_actions.call_count, 0)


@patch.object(engine, 'check_condition', return_value=True)
def test_check_all_conditions_with_all_true(self, *args):
conditions = {'all': [{'thing1': ''}, {'thing2': ''}]}
Expand Down
Loading