conditions.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import functools
  2. import re
  3. from django.utils.translation import gettext as _
  4. __all__ = (
  5. 'Condition',
  6. 'ConditionSet',
  7. )
  8. AND = 'and'
  9. OR = 'or'
  10. def is_ruleset(data):
  11. """
  12. Determine whether the given dictionary looks like a rule set.
  13. """
  14. return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
  15. class Condition:
  16. """
  17. An individual conditional rule that evaluates a single attribute and its value.
  18. :param attr: The name of the attribute being evaluated
  19. :param value: The value being compared
  20. :param op: The logical operation to use when evaluating the value (default: 'eq')
  21. """
  22. EQ = 'eq'
  23. GT = 'gt'
  24. GTE = 'gte'
  25. LT = 'lt'
  26. LTE = 'lte'
  27. IN = 'in'
  28. CONTAINS = 'contains'
  29. REGEX = 'regex'
  30. OPERATORS = (
  31. EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX
  32. )
  33. TYPES = {
  34. str: (EQ, CONTAINS, REGEX),
  35. bool: (EQ, CONTAINS),
  36. int: (EQ, GT, GTE, LT, LTE, CONTAINS),
  37. float: (EQ, GT, GTE, LT, LTE, CONTAINS),
  38. list: (EQ, IN, CONTAINS),
  39. type(None): (EQ,)
  40. }
  41. def __init__(self, attr, value, op=EQ, negate=False):
  42. if op not in self.OPERATORS:
  43. raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
  44. op=op, operators=', '.join(self.OPERATORS)
  45. ))
  46. if type(value) not in self.TYPES:
  47. raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
  48. if op not in self.TYPES[type(value)]:
  49. raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
  50. self.attr = attr
  51. self.value = value
  52. self.eval_func = getattr(self, f'eval_{op}')
  53. self.negate = negate
  54. def eval(self, data):
  55. """
  56. Evaluate the provided data to determine whether it matches the condition.
  57. """
  58. def _get(obj, key):
  59. if isinstance(obj, list):
  60. return [dict.get(i, key) for i in obj]
  61. return dict.get(obj, key)
  62. try:
  63. value = functools.reduce(_get, self.attr.split('.'), data)
  64. except TypeError:
  65. # Invalid key path
  66. value = None
  67. result = self.eval_func(value)
  68. if self.negate:
  69. return not result
  70. return result
  71. # Equivalency
  72. def eval_eq(self, value):
  73. return value == self.value
  74. def eval_neq(self, value):
  75. return value != self.value
  76. # Numeric comparisons
  77. def eval_gt(self, value):
  78. return value > self.value
  79. def eval_gte(self, value):
  80. return value >= self.value
  81. def eval_lt(self, value):
  82. return value < self.value
  83. def eval_lte(self, value):
  84. return value <= self.value
  85. # Membership
  86. def eval_in(self, value):
  87. return value in self.value
  88. def eval_contains(self, value):
  89. return self.value in value
  90. # Regular expressions
  91. def eval_regex(self, value):
  92. return re.match(self.value, value) is not None
  93. class ConditionSet:
  94. """
  95. A set of one or more Condition to be evaluated per the prescribed logic (AND or OR). Example:
  96. {"and": [
  97. {"attr": "foo", "op": "eq", "value": 1},
  98. {"attr": "bar", "op": "eq", "value": 2, "negate": true}
  99. ]}
  100. :param ruleset: A dictionary mapping a logical operator to a list of conditional rules
  101. """
  102. def __init__(self, ruleset):
  103. if type(ruleset) is not dict:
  104. raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
  105. if len(ruleset) != 1:
  106. raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
  107. ruleset=len(ruleset)))
  108. # Determine the logic type
  109. logic = list(ruleset.keys())[0]
  110. if type(logic) is not str or logic.lower() not in (AND, OR):
  111. raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
  112. logic=logic, op_and=AND, op_or=OR
  113. ))
  114. self.logic = logic.lower()
  115. # Compile the set of Conditions
  116. self.conditions = [
  117. ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
  118. for rule in ruleset[self.logic]
  119. ]
  120. def eval(self, data):
  121. """
  122. Evaluate the provided data to determine whether it matches this set of conditions.
  123. """
  124. func = any if self.logic == 'or' else all
  125. return func(d.eval(data) for d in self.conditions)