Skip to content
This repository has been archived by the owner on Feb 13, 2022. It is now read-only.

Commit

Permalink
Merge branch 'feature/issue-38-implement-combination-rule-logic-in-ba…
Browse files Browse the repository at this point in the history
…ckend' into develop
  • Loading branch information
nathanbegbie committed Nov 6, 2017
2 parents f734f94 + 4531c23 commit ec18947
Show file tree
Hide file tree
Showing 4 changed files with 326 additions and 1 deletion.
123 changes: 123 additions & 0 deletions molo/surveys/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,100 @@
from django.utils import timezone

from molo.core.models import ArticlePage
from .rules import CombinationRule


def get_rule(rule_hash, data_structure):
rule_type, order = rule_hash.split('_')
return data_structure[rule_type][int(order)]


def index_rules_by_type(rules):
'''
Indexes a list of rules by type name,
while maintaining rule order for each rule type
Sample Input:
[
<TimeRule: Time Rule>,
<TimeRule: Time Rule>,
<UserIsLoggedInRule: Logged in Rule>
]
Sample Output:
{
'TimeRule': [ <TimeRule: Time Rule>, <TimeRule: Time Rule> ],
'UserIsLoggedInRule': [ <UserIsLoggedInRule: Logged in Rule> ]
}
'''
indexed_rules = {}
for rule in rules:
type_name = type(rule).__name__
if type_name in indexed_rules:
indexed_rules[type_name].append(rule)
else:
indexed_rules[type_name] = [rule]
return indexed_rules


def transform_into_boolean_list(stream_data, indexed_rules, request):
'''
Converts a stream field of strings and rules into a list
of booleans (evaluated rule evaluations), strings and nested lists
Sample Input:
[
{u'type': u'Rule', u'value': u'UserIsLoggedInRule_0'},
{u'type': u'Operator', u'value': u'and'},
{u'type': u'NestedLogic', u'value': {
u'operator': u'or',
u'rule_1': u'TimeRule_0',
u'rule_2': u'TimeRule_1'}
}
]
Output:
[True, 'and', [False, 'or', True]]
'''
return_value = []
for block in stream_data:
if block['type'] == 'Rule':
rule = get_rule(block['value'], indexed_rules)
return_value.append(rule.test_user(request))
elif block['type'] == 'Operator':
return_value.append(block['value'])
elif block['type'] == 'NestedLogic':
values = block['value']
rule_1 = get_rule(values['rule_1'], indexed_rules)
rule_2 = get_rule(values['rule_2'], indexed_rules)
return_value.append([
rule_1.test_user(request),
values['operator'],
rule_2.test_user(request)
])

return return_value


def evaluate(list_):
'''
Function that evaluates a list of boolean values
seperated by strings that represent boolean values
i.e. 'and', 'or'
Sample Input:
'''
if len(list_) == 3:
operator = list_[1]
first = list_[0] if isinstance(list_[0], bool) else evaluate(list_[0])
second = list_[2] if isinstance(list_[2], bool) else evaluate(list_[2])
if operator == 'or':
return first or second
else:
return first and second
else:
return evaluate([evaluate(list_[:3])] + list_[3:])


class SurveysSegmentsAdapter(SessionSegmentsAdapter):
Expand Down Expand Up @@ -44,3 +138,32 @@ def get_tag_count(self, tag, date_from=None, date_to=None):
valid_visits = [visit for visit in visits.values()
if date_from <= parse_datetime(visit) <= date_to]
return len(valid_visits)

def _test_rules(self, rules, request, match_any=False):
if not rules:
return False

bool_rules = False
bool_rules = [rule for rule in rules
if isinstance(rule, CombinationRule)]

if not bool_rules:
if match_any:
return any(rule.test_user(request) for rule in rules)
return all(rule.test_user(request) for rule in rules)
else:
# evaluates only 1 rule
rule_combo = bool_rules[0]

simple_rules = [rule for rule in rules
if not isinstance(rule, CombinationRule)]

rules_indexed_by_type_name = index_rules_by_type(simple_rules)

nested_list_of_booleans = transform_into_boolean_list(
rule_combo.body.stream_data,
rules_indexed_by_type_name,
request
)

return evaluate(nested_list_of_booleans)
25 changes: 25 additions & 0 deletions molo/surveys/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,28 @@ def clean(self, value):
cleaned_data['question'] = None

return cleaned_data


class RuleSelectBlock(blocks.CharBlock):

class Meta:
icon = 'cog'


class AndOrBlock(blocks.ChoiceBlock):
choices = [
('and', _('And')),
('or', _('Or'))
]

class Meta:
icon = 'plus'


class LogicBlock(blocks.StructBlock):
rule_1 = RuleSelectBlock(required=True)
operator = AndOrBlock(required=True)
rule_2 = RuleSelectBlock(required=True)

class Meta:
icon = 'cogs'
27 changes: 26 additions & 1 deletion molo/surveys/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
FieldPanel,
FieldRowPanel,
PageChooserPanel,
StreamFieldPanel,
)
from wagtail_personalisation.adapters import get_segment_adapter
from wagtail_personalisation.rules import AbstractBaseRule, VisitCountRule

from molo.core.models import ArticlePageTags

from .edit_handlers import TagPanel

from molo.surveys import blocks


# Filer the Visit Count Page only by articles
VisitCountRule._meta.verbose_name = 'Page Visit Count Rule'
Expand Down Expand Up @@ -348,6 +350,7 @@ def clean(self):
)

def test_user(self, request):
from wagtail_personalisation.adapters import get_segment_adapter
operator = self.OPERATORS[self.operator]
adapter = get_segment_adapter(request)
visit_count = adapter.get_tag_count(
Expand All @@ -368,3 +371,25 @@ def description(self):
self.count
),
}


class CombinationRule(AbstractBaseRule):
body = blocks.StreamField([
('Rule', blocks.RuleSelectBlock()),
('Operator', blocks.AndOrBlock()),
('NestedLogic', blocks.LogicBlock())
])

panels = [
StreamFieldPanel('body'),
]

def description(self):
return {
'title': _(
'Based on whether they satisfy a '
'particular combination of rules'),
}

class Meta:
verbose_name = _('Rule Combination')
152 changes: 152 additions & 0 deletions molo/surveys/tests/test_adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.test import TestCase, RequestFactory
from django.test.client import Client

from wagtail_personalisation.rules import UserIsLoggedInRule

from molo.core.models import Main
from molo.core.tests.base import MoloTestCaseMixin

from molo.surveys.adapters import (
get_rule,
index_rules_by_type,
transform_into_boolean_list,
evaluate,
)

from molo.surveys.rules import GroupMembershipRule


class TestAdapterUtils(TestCase, MoloTestCaseMixin):
def setUp(self):
self.client = Client()
self.mk_main()
self.main = Main.objects.all().first()

self.request_factory = RequestFactory()
self.request = self.request_factory.get('/')
self.request.user = get_user_model().objects.create_user(
username='tester', email='tester@example.com', password='tester')

self.group_1 = Group.objects.create(name='Group 1')
self.group_2 = Group.objects.create(name='Group 2')

self.request.user.groups.add(self.group_1)

def test_get_rule(self):
fake_ds = {
'TimeRule': ['first time rule', 'second time rule'],
'UserIsLoggedInRule': ['first logged in rule']
}

self.assertEqual(get_rule('TimeRule_0', fake_ds),
fake_ds["TimeRule"][0])
self.assertEqual(get_rule('TimeRule_1', fake_ds),
fake_ds["TimeRule"][1])
self.assertEqual(get_rule('UserIsLoggedInRule_0', fake_ds),
fake_ds["UserIsLoggedInRule"][0])

def test_index_rules_by_type(self):
group_rule_1 = GroupMembershipRule(group=self.group_1)
group_rule_2 = GroupMembershipRule(group=self.group_2)
logged_in_rule = UserIsLoggedInRule(is_logged_in=True)

test_input = [logged_in_rule, group_rule_1, group_rule_2]
expected_output = {
'GroupMembershipRule': [group_rule_1, group_rule_2],
'UserIsLoggedInRule': [logged_in_rule]
}

self.assertEqual(
index_rules_by_type(test_input),
expected_output)

def test_transform_into_boolean_list_simple(self):
group_rule_1 = GroupMembershipRule(group=self.group_1)
group_rule_2 = GroupMembershipRule(group=self.group_2)
logged_in_rule = UserIsLoggedInRule(is_logged_in=True)

sample_stream_data = [
{u'type': u'Rule', u'value': u'UserIsLoggedInRule_0'},
{u'type': u'Operator', u'value': u'and'},
{
u'type': u'NestedLogic',
u'value': {
u'operator': u'or',
u'rule_1': u'GroupMembershipRule_0',
u'rule_2': u'GroupMembershipRule_1'}
}
]

sample_indexed_rules = {
'GroupMembershipRule': [group_rule_1, group_rule_2],
'UserIsLoggedInRule': [logged_in_rule]
}

self.assertEqual(
transform_into_boolean_list(
sample_stream_data,
sample_indexed_rules,
self.request
),
[logged_in_rule.test_user(self.request),
u'and',
[
group_rule_1.test_user(self.request),
u'or',
group_rule_2.test_user(self.request)
]]
)

def test_evaluate_1(self):
self.assertEqual(
(False or True),
evaluate([False, "or", True])
)

def test_evaluate_2(self):
self.assertEqual(
(False and True or True),
evaluate([False, "and", True, "or", True])
)

def test_evaluate_3(self):
self.assertEqual(
(False and True),
evaluate([False, "and", True])
)

def test_evaluate_4(self):
self.assertEqual(
((False or True) and True),
evaluate([[False, "or", True], "and", True])
)

def test_evaluate_5(self):
self.assertEqual(
((False or True) and (False and False)),
evaluate([[False, "or", True], "and", [False, "and", False]])
)

def test_evaluate_6(self):
self.assertEqual(
((False or
(True or False)) and
(False and False)),
evaluate(
[[False, "or",
[True, "or", False]], "and",
[False, "and", False]])
)

def test_evaluate_7(self):
self.assertEqual(
((False or True) and
(False and False) or
(True and False)),
evaluate(
[[False, "or", True], "and",
[False, "and", False], "or",
[True, 'and', False]])
)

0 comments on commit ec18947

Please sign in to comment.