# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-2021 rami.io GmbH and contributors # # This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General # Public License as published by the Free Software Foundation in version 3 of the License. # # ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are # applicable granting you additional permissions and placing additional restrictions on your usage of this software. # Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive # this file, see . # # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # """ This is a Python implementation of the following jsonLogic JS library: https://github.com/jwadhams/json-logic-js Implementation is built upon the implementation at https://github.com/nadirizr/json-logic-py Copyright (c) 2015 nadirizr, The MIT License We vendor this library since it is simple enough and upstream seems unmaintained. In particular, we changed: * Full test coverage * Fully passing tests against shared tests suite at 2020-04-19 * Option to add custom operations """ import logging from functools import reduce logger = logging.getLogger(__name__) def if_(*args): """Implements the 'if' operator with support for multiple elseif-s.""" for i in range(0, len(args) - 1, 2): if args[i]: return args[i + 1] if len(args) % 2: return args[-1] else: return None def soft_equals(a, b): """Implements the '==' operator, which does type JS-style coertion.""" if isinstance(a, str) or isinstance(b, str): return str(a) == str(b) if isinstance(a, bool) or isinstance(b, bool): return bool(a) is bool(b) return a == b def hard_equals(a, b): """Implements the '===' operator.""" if type(a) is not type(b): return False return a == b def less(a, b, *args): """Implements the '<' operator with JS-style type coertion.""" types = set([type(a), type(b)]) if float in types or int in types: try: a, b = float(a), float(b) except (TypeError, ValueError): # NaN return False return a < b and (not args or less(b, *args)) def less_or_equal(a, b, *args): """Implements the '<=' operator with JS-style type coertion.""" return ( less(a, b) or soft_equals(a, b) ) and (not args or less_or_equal(b, *args)) def to_numeric(arg): """ Converts a string either to int or to float. This is important, because e.g. {"!==": [{"+": "0"}, 0.0]} """ if isinstance(arg, str): if '.' in arg: return float(arg) else: return int(arg) return arg def plus(*args): """Sum converts either to ints or to floats.""" return sum(to_numeric(arg) for arg in args) def minus(*args): """Also, converts either to ints or to floats.""" if len(args) == 1: return -to_numeric(args[0]) return to_numeric(args[0]) - to_numeric(args[1]) def merge(*args): """Implements the 'merge' operator for merging lists.""" ret = [] for arg in args: if isinstance(arg, (list, tuple)): ret += list(arg) else: ret.append(arg) return ret def get_var(data, var_name="", not_found=None): """Gets variable value from data dictionary.""" if var_name == "" or var_name is None: return data try: for key in str(var_name).split('.'): try: data = data[key] except TypeError: data = data[int(key)] except (KeyError, TypeError, ValueError): return not_found else: return data def missing(data, *args): """Implements the missing operator for finding missing variables.""" not_found = object() if args and isinstance(args[0], list): args = args[0] ret = [] for arg in args: if get_var(data, arg, not_found) is not_found: ret.append(arg) return ret def missing_some(data, min_required, args): """Implements the missing_some operator for finding missing variables.""" if min_required < 1: return [] found = 0 not_found = object() ret = [] for arg in args: if get_var(data, arg, not_found) is not_found: ret.append(arg) else: found += 1 if found >= min_required: return [] return ret operations = { "==": soft_equals, "===": hard_equals, "!=": lambda a, b: not soft_equals(a, b), "!==": lambda a, b: not hard_equals(a, b), ">": lambda a, b: less(b, a), ">=": lambda a, b: less(b, a) or soft_equals(a, b), "<": less, "<=": less_or_equal, "!": lambda a: not a, "!!": bool, "%": lambda a, b: a % b, "and": lambda *args: reduce(lambda total, arg: total and arg, args, True), "or": lambda *args: reduce(lambda total, arg: total or arg, args, False), "?:": lambda a, b, c: b if a else c, "if": if_, "log": lambda a: logger.info(a) or a, "in": lambda a, b: a in b if "__contains__" in dir(b) else False, "cat": lambda *args: "".join(str(arg) for arg in args), "+": plus, "*": lambda *args: reduce(lambda total, arg: total * float(arg), args, 1), "-": minus, "/": lambda a, b=None: a if b is None else float(a) / float(b), "min": lambda *args: min(args), "max": lambda *args: max(args), "merge": merge, "count": lambda *args: sum(1 if a else 0 for a in args), "substr": lambda a, b, c=None: a[b:] if c is None else a[b:][:c], } class Logic(): def __init__(self): self._operations = {} def add_operation(self, name, func): self._operations[name] = func def apply(self, tests, data=None): """Executes the json-logic with given data.""" # You've recursed to a primitive, stop! if tests is None or not isinstance(tests, dict): return tests data = data or {} operator = [k for k in tests.keys() if not k.startswith("__")][0] values = tests[operator] # Easy syntax for unary operators, like {"var": "x"} instead of strict # {"var": ["x"]} if not isinstance(values, list) and not isinstance(values, tuple): values = [values] # Array-level operations if operator == 'none': return not any(self.apply(values[1], i) for i in self.apply(values[0], data)) if operator == 'all': elements = self.apply(values[0], data) if not elements: return False return all(self.apply(values[1], i) for i in elements) if operator == 'some': return any(self.apply(values[1], i) for i in self.apply(values[0], data)) if operator == 'reduce': return reduce( lambda acc, el: self.apply(values[1], {'current': el, 'accumulator': acc}), self.apply(values[0], data) or [], self.apply(values[2], data) ) if operator == 'map': return [ self.apply(values[1], i) for i in (self.apply(values[0], data) or []) ] if operator == 'filter': return [ i for i in self.apply(values[0], data) if self.apply(values[1], i) ] # Recursion! values = [self.apply(val, data) for val in values] if operator == 'var': return get_var(data, *values) if operator == 'missing': return missing(data, *values) if operator == 'missing_some': return missing_some(data, *values) if operator in operations: return operations[operator](*values) elif operator in self._operations: return self._operations[operator](*values) else: raise ValueError("Unrecognized operation %s" % operator)