import functools import re from django.utils.translation import gettext as _ __all__ = ( 'Condition', 'ConditionSet', ) AND = 'and' OR = 'or' def is_ruleset(data): """ Determine whether the given dictionary looks like a rule set. """ return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR) class Condition: """ An individual conditional rule that evaluates a single attribute and its value. :param attr: The name of the attribute being evaluated :param value: The value being compared :param op: The logical operation to use when evaluating the value (default: 'eq') """ EQ = 'eq' GT = 'gt' GTE = 'gte' LT = 'lt' LTE = 'lte' IN = 'in' CONTAINS = 'contains' REGEX = 'regex' OPERATORS = ( EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX ) TYPES = { str: (EQ, CONTAINS, REGEX), bool: (EQ, CONTAINS), int: (EQ, GT, GTE, LT, LTE, CONTAINS), float: (EQ, GT, GTE, LT, LTE, CONTAINS), list: (EQ, IN, CONTAINS), type(None): (EQ,) } def __init__(self, attr, value, op=EQ, negate=False): if op not in self.OPERATORS: raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format( op=op, operators=', '.join(self.OPERATORS) )) if type(value) not in self.TYPES: raise ValueError(_("Unsupported value type: {value}").format(value=type(value))) if op not in self.TYPES[type(value)]: raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value))) self.attr = attr self.value = value self.eval_func = getattr(self, f'eval_{op}') self.negate = negate def eval(self, data): """ Evaluate the provided data to determine whether it matches the condition. """ def _get(obj, key): if isinstance(obj, list): return [dict.get(i, key) for i in obj] return dict.get(obj, key) try: value = functools.reduce(_get, self.attr.split('.'), data) except TypeError: # Invalid key path value = None result = self.eval_func(value) if self.negate: return not result return result # Equivalency def eval_eq(self, value): return value == self.value def eval_neq(self, value): return value != self.value # Numeric comparisons def eval_gt(self, value): return value > self.value def eval_gte(self, value): return value >= self.value def eval_lt(self, value): return value < self.value def eval_lte(self, value): return value <= self.value # Membership def eval_in(self, value): return value in self.value def eval_contains(self, value): return self.value in value # Regular expressions def eval_regex(self, value): return re.match(self.value, value) is not None class ConditionSet: """ A set of one or more Condition to be evaluated per the prescribed logic (AND or OR). Example: {"and": [ {"attr": "foo", "op": "eq", "value": 1}, {"attr": "bar", "op": "eq", "value": 2, "negate": true} ]} :param ruleset: A dictionary mapping a logical operator to a list of conditional rules """ def __init__(self, ruleset): if type(ruleset) is not dict: raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset))) if len(ruleset) != 1: raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format( ruleset=len(ruleset))) # Determine the logic type logic = list(ruleset.keys())[0] if type(logic) is not str or logic.lower() not in (AND, OR): raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format( logic=logic, op_and=AND, op_or=OR )) self.logic = logic.lower() # Compile the set of Conditions self.conditions = [ ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) for rule in ruleset[self.logic] ] def eval(self, data): """ Evaluate the provided data to determine whether it matches this set of conditions. """ func = any if self.logic == 'or' else all return func(d.eval(data) for d in self.conditions)