diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 2a08ee50ad..fd2c01b0f5 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -697,6 +697,9 @@ Order position endpoints * ``product`` - Tickets with this product may not be scanned at this device * ``rules`` - Check-in prevented by a user-defined rule + In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable + description of the violated rules. However, that field can also be missing or be ``null``. + :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch :param list: The ID of the check-in list to look for diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index a403af275a..c2750a1be3 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -395,52 +395,54 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): except ValidationError: pass - try: - perform_checkin( - op=op, - clist=self.checkinlist, - given_answers=given_answers, - force=force, - ignore_unpaid=ignore_unpaid, - nonce=nonce, - datetime=dt, - questions_supported=self.request.data.get('questions_supported', True), - canceled_supported=self.request.data.get('canceled_supported', False), - user=self.request.user, - auth=self.request.auth, - type=type, - ) - except RequiredQuestionsError as e: - return Response({ - 'status': 'incomplete', - 'require_attention': op.item.checkin_attention or op.order.checkin_attention, - 'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data, - 'questions': [ - QuestionSerializer(q).data for q in e.questions - ] - }, status=400) - except CheckInError as e: - op.order.log_action('pretix.event.checkin.denied', data={ - 'position': op.id, - 'positionid': op.positionid, - 'errorcode': e.code, - 'force': force, - 'datetime': dt, - 'type': type, - 'list': self.checkinlist.pk - }, user=self.request.user, auth=self.request.auth) - return Response({ - 'status': 'error', - 'reason': e.code, - 'require_attention': op.item.checkin_attention or op.order.checkin_attention, - 'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data - }, status=400) - else: - return Response({ - 'status': 'ok', - 'require_attention': op.item.checkin_attention or op.order.checkin_attention, - 'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data - }, status=201) + with language(self.request.event.settings.locale): + try: + perform_checkin( + op=op, + clist=self.checkinlist, + given_answers=given_answers, + force=force, + ignore_unpaid=ignore_unpaid, + nonce=nonce, + datetime=dt, + questions_supported=self.request.data.get('questions_supported', True), + canceled_supported=self.request.data.get('canceled_supported', False), + user=self.request.user, + auth=self.request.auth, + type=type, + ) + except RequiredQuestionsError as e: + return Response({ + 'status': 'incomplete', + 'require_attention': op.item.checkin_attention or op.order.checkin_attention, + 'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data, + 'questions': [ + QuestionSerializer(q).data for q in e.questions + ] + }, status=400) + except CheckInError as e: + op.order.log_action('pretix.event.checkin.denied', data={ + 'position': op.id, + 'positionid': op.positionid, + 'errorcode': e.code, + 'force': force, + 'datetime': dt, + 'type': type, + 'list': self.checkinlist.pk + }, user=self.request.user, auth=self.request.auth) + return Response({ + 'status': 'error', + 'reason': e.code, + 'reason_explanation': e.reason, + 'require_attention': op.item.checkin_attention or op.order.checkin_attention, + 'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data + }, status=400) + else: + return Response({ + 'status': 'ok', + 'require_attention': op.item.checkin_attention or op.order.checkin_attention, + 'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data + }, status=201) def _handle_file_upload(self, data): try: diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index d9c5a5a3d9..e2f71a05d5 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -185,6 +185,7 @@ class CheckinList(LoggedModel): # Every change to our supported JSON logic must be done # * in pretix.base.services.checkin # * in pretix.base.models.checkin + # * in pretix.helpers.jsonlogic_boolalg # * in checkinrules.js # * in libpretixsync top_level_operators = { diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 20c81e05c3..d8d815bebf 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -46,6 +46,7 @@ from django.db.models import ( ) from django.db.models.functions import Coalesce, TruncDate from django.dispatch import receiver +from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.timezone import make_aware, now, override from django.utils.translation import gettext as _ @@ -56,27 +57,198 @@ from pretix.base.models import ( ) from pretix.base.signals import checkin_created, order_placed, periodic_task from pretix.helpers.jsonlogic import Logic +from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_query import ( Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan, tolerance, ) -def get_logic_environment(ev): +def _build_time(t=None, value=None, ev=None): + if t == "custom": + return dateutil.parser.parse(value) + elif t == 'date_from': + return ev.date_from + elif t == 'date_to': + return ev.date_to or ev.date_from + elif t == 'date_admission': + return ev.date_admission or ev.date_from + + +def _logic_explain(rules, ev, rule_data): + """ + Explains when the logic denied the check-in. Only works for a denied check-in. + + While our custom check-in logic is very flexible, its main problem is that it is pretty + intransparent during execution. If the logic causes an entry to be forbidden, the result + of the logic evaluation is just a simple ``False``, which is very unhelpful to explain to + attendees why they don't get into the event. + + The main problem with fixing this is that there is no correct answer for this, it is always + up for interpretation. A good example is the following set of rules: + + - Attendees with a regular ticket can enter the venue between 09:00 and 17:00 on three days + - Attendees with a VIP ticket can enter the venue between 08:00 and 18:00 on three days + + If an attendee with a regular ticket now shows up at 17:30 on the first day, there are three + possible error messages: + + a) You do not have a VIP ticket + b) You can only get in before 17:00 + c) You can only get in after 09:00 tomorrow + + All three of them are just as valid, and "fixing" either one of them would get the attendee in. + Showing all three is too much, especially since the list can get very long with complex logic. + + We therefore make an opinionated choice based on a number of assumptions. An example for these + assumptions is "it is very unlikely that the attendee is unable to change their ticket type". + Additionally, we favor a "close failure". Therefore, in the above example, we'd show "You can only + get in before 17:00". In the middle of the night it would switch to "You can only get in after 09:00". + """ + logic_environment = _get_logic_environment(ev) + _var_values = {'False': False, 'True': True} + _var_explanations = {} + + # Step 1: To simplify things later, we replace every operator of the rule that + # is NOT a boolean operator (AND and OR in our case) with the evaluation result. + def _evaluate_inners(r): + if r is True: + return {'var': 'True'} + if r is False: + return {'var': 'False'} + if not isinstance(r, dict): + return r + operator = list(r.keys())[0] + values = r[operator] + if operator in ("and", "or"): + return {operator: [_evaluate_inners(v) for v in values]} + result = logic_environment.apply(r, rule_data) + new_var_name = f'v{len(_var_values)}' + _var_values[new_var_name] = result + if not result: + # Operator returned false, let's dig deeper + if "var" not in values[0]: + raise ValueError("Binary operators should be normalized to have a variable on their left-hand side") + if isinstance(values[0]["var"], list): + values[0]["var"] = values[0]["var"][0] + _var_explanations[new_var_name] = { + 'operator': operator, + 'var': values[0]["var"], + 'rhs': values[1:], + } + return {'var': new_var_name} + try: + rules = _evaluate_inners(rules) + except ValueError: + return _('Unknown reason') + + # Step 2: Transform the the logic into disjunctive normal form (max. one level of ANDs nested in max. one level + # of ORs), e.g. `(a AND b AND c) OR (d AND e)` + rules = convert_to_dnf(rules) + + # Step 3: Split into the various paths to truthiness, e.g. ``[[a, b, c], [d, e]]`` for our sample above + paths = [] + if "and" in rules: + # only one path + paths.append([v["var"] for v in rules["and"]]) + elif "or" in rules: + # multiple paths + for r in rules["or"]: + if "and" in r: + paths.append([v["var"] for v in r["and"]]) + else: + paths.append([r["var"]]) + else: + # only one expression on only one path + paths.append([rules["var"]]) + + # Step 4: For every variable with value False, compute a weight. The weight is a 2-tuple of numbers. + # The first component indicates a "rigidness level". The higher the rigidness, the less likely it is that the + # outcome is determined by some action of the attendee. For example, the number of entries has a very low + # rigidness since the attendee decides how often they enter. The current time has a medium rigidness + # since the attendee decides when they show up. The product has a high rigidness, since customers usually + # can't change what type of ticket they have. + # The second component indicates the "error size". For example for a date comparision this would be the number of + # seconds between the two dates. + # Additionally, we compute a text for every variable. + var_weights = { + 'False': (100000, 0), # used during testing + 'True': (100000, 0), # used during testing + } + var_texts = { + 'False': 'Always false', # used during testing + 'True': 'Always true', # used during testing + } + for vname, data in _var_explanations.items(): + var, operator, rhs = data['var'], data['operator'], data['rhs'] + if var == 'now': + compare_to = _build_time(*rhs[0]['buildTime'], ev=ev).astimezone(ev.timezone) + tolerance = timedelta(minutes=float(rhs[1])) if len(rhs) > 1 and rhs[1] else timedelta(seconds=0) + if operator == 'isBefore': + compare_to += tolerance + else: + compare_to -= tolerance + + var_weights[vname] = (200, abs(now() - compare_to).total_seconds()) + + if abs(now() - compare_to) < timedelta(hours=12): + compare_to_text = date_format(compare_to, 'TIME_FORMAT') + else: + compare_to_text = date_format(compare_to, 'SHORT_DATETIME_FORMAT') + if operator == 'isBefore': + var_texts[vname] = _('Only allowed before {datetime}').format(datetime=compare_to_text) + elif operator == 'isAfter': + var_texts[vname] = _('Only allowed after {datetime}').format(datetime=compare_to_text) + elif var == 'product' or var == 'variation': + var_weights[vname] = (1000, 0) + var_texts[vname] = _('Ticket type not allowed') + elif var in ('entries_number', 'entries_today', 'entries_days'): + w = { + 'entries_days': 100, + 'entries_number': 120, + 'entries_today': 140, + } + l = { + 'entries_days': _('number of days with an entry'), + 'entries_number': _('number of entries'), + 'entries_today': _('number of entries today'), + } + compare_to = rhs[0] + var_weights[vname] = (w[var], abs(compare_to - rule_data[var])) + if operator == '==': + var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to) + elif operator in ('<', '<='): + var_texts[vname] = _('Maximum {variable} exceeded').format(variable=l[var]) + elif operator in ('>', '>='): + var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var]) + elif operator == '!=': + var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to) + else: + raise ValueError(f'Unknown variable {var}') + + # Step 5: For every path, compute the maximum weight + path_weights = [ + max([ + var_weights[v] for v in path if not _var_values[v] + ] or [(0, 0)]) for path in paths + ] + + # Step 6: Find the paths with the minimum weight + min_weight = min(path_weights) + paths_with_min_weight = [ + p for i, p in enumerate(paths) if path_weights[i] == min_weight + ] + + # Finally, return the text for one of them + return ', '.join(var_texts[v] for v in paths_with_min_weight[0] if not _var_values[v]) + + +def _get_logic_environment(ev): # Every change to our supported JSON logic must be done # * in pretix.base.services.checkin # * in pretix.base.models.checkin # * in checkinrules.js # * in libpretixsync - def build_time(t=None, value=None): - if t == "custom": - return dateutil.parser.parse(value) - elif t == 'date_from': - return ev.date_from - elif t == 'date_to': - return ev.date_to or ev.date_from - elif t == 'date_admission': - return ev.date_admission or ev.date_from def is_before(t1, t2, tolerance=None): if tolerance: @@ -88,7 +260,7 @@ def get_logic_environment(ev): logic.add_operation('objectList', lambda *objs: list(objs)) logic.add_operation('lookup', lambda model, pk, str: int(pk)) logic.add_operation('inList', lambda a, b: a in b) - logic.add_operation('buildTime', build_time) + logic.add_operation('buildTime', partial(_build_time, ev=ev)) logic.add_operation('isBefore', is_before) logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol)) return logic @@ -141,7 +313,7 @@ class SQLLogic: This is a simplified implementation of JSON logic that creates a Q-object to be used in a QuerySet. It does not implement all operations supported by JSON logic and makes a few simplifying assumptions, but all that can be created through our graphical editor. There's also CheckinList.validate_rules() - which tries to validate the same preconditions for rules set throught he API (probably not perfect). + which tries to validate the same preconditions for rules set through the API (probably not perfect). Assumptions: @@ -308,9 +480,10 @@ class SQLLogic: class CheckInError(Exception): - def __init__(self, msg, code): + def __init__(self, msg, code, reason=None): self.msg = msg self.code = code + self.reason = reason super().__init__(msg) @@ -443,11 +616,15 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, if type == Checkin.TYPE_ENTRY and clist.rules and not force: rule_data = LazyRuleVars(op, clist, dt) - logic = get_logic_environment(op.subevent or clist.event) + logic = _get_logic_environment(op.subevent or clist.event) if not logic.apply(clist.rules, rule_data): + reason = _logic_explain(clist.rules, op.subevent or clist.event, rule_data) raise CheckInError( - _('This entry is not permitted due to custom rules.'), - 'rules' + _('Entry not permitted: {explanation}.').format( + explanation=reason + ), + 'rules', + reason=reason ) device = None diff --git a/src/pretix/helpers/jsonlogic_boolalg.py b/src/pretix/helpers/jsonlogic_boolalg.py new file mode 100644 index 0000000000..58162d840a --- /dev/null +++ b/src/pretix/helpers/jsonlogic_boolalg.py @@ -0,0 +1,90 @@ +# +# 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 +# . +# +import logging + +logger = logging.getLogger(__name__) + + +def convert_to_dnf(rules): + """ + Converts a set of rules to disjunctive normal form, i.e. returns something of the form + `(a AND b AND c) OR (a AND d AND f)` + without further nesting. + """ + if not isinstance(rules, dict): + return rules + + def _distribute_or_over_and(r): + operator = list(r.keys())[0] + values = rules[operator] + if operator == "and": + arg_to_distribute = [arg for arg in values if isinstance(arg, dict) and "or" in arg] + if not arg_to_distribute: + return rules + arg_to_distribute = arg_to_distribute[0] + other_args = [arg for arg in values if arg is not arg_to_distribute] + return { + "or": [ + {"and": [*other_args, dval]} for dval in arg_to_distribute["or"] + ] + } + elif operator in ("!", "!!", "?:", "if"): + raise ValueError(f"Operator {operator} currently unsupported by convert_to_dnf") + else: + return r + + def _simplify_chained_operators(r): + # Simplify `(a OR b) OR (c or d)` to `a OR b OR c OR d` and the same with `AND` + if not isinstance(r, dict): + return r + operator = list(r.keys())[0] + values = rules[operator] + if operator not in ("or", "and"): + return r + new_values = [] + for v in values: + if not isinstance(v, dict) or operator not in v: + new_values.append(v) + else: + new_values += v[operator] + return {operator: new_values} + + # Run _distribute_or_over_and on until it no longer changes anything. Do so recursively + # for the full expression tree. + old_rules = rules + while True: + rules = _distribute_or_over_and(rules) + operator = list(rules.keys())[0] + values = rules[operator] + if not isinstance(values, list): + values = [values] + rules = { + operator: [ + convert_to_dnf(v) for v in values + ] if len(values) > 1 else convert_to_dnf(values[0]) + } + if old_rules == rules: + break + old_rules = rules + # Simplify leftovers of the recursion + rules = _simplify_chained_operators(rules) + return rules diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index b15725dbe4..11536bcb5d 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -413,6 +413,7 @@ def test_rules_product(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Ticket type not allowed' in str(excinfo.value) clist.rules = { "inList": [ @@ -449,6 +450,7 @@ def test_rules_variation(item, position, clist): perform_checkin(position, clist, {}) assert not OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() assert excinfo.value.code == 'rules' + assert 'Ticket type not allowed' in str(excinfo.value) clist.rules = { "inList": [ @@ -481,6 +483,7 @@ def test_rules_scan_number(position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Maximum number of entries' in str(excinfo.value) @pytest.mark.django_db @@ -500,12 +503,14 @@ def test_rules_scan_today(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Maximum number of entries today' in str(excinfo.value) with freeze_time("2020-01-01 22:50:00"): assert not OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Maximum number of entries today' in str(excinfo.value) with freeze_time("2020-01-01 23:10:00"): assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() @@ -516,6 +521,7 @@ def test_rules_scan_today(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Maximum number of entries today' in str(excinfo.value) @pytest.mark.django_db @@ -547,6 +553,7 @@ def test_rules_scan_days(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Maximum number of days with an entry exceeded.' in str(excinfo.value) @pytest.mark.django_db @@ -562,6 +569,7 @@ def test_rules_time_isafter_tolerance(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Only allowed after 11:50' in str(excinfo.value) with freeze_time("2020-01-01 10:51:00"): assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() @@ -582,6 +590,7 @@ def test_rules_time_isafter_no_tolerance(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Only allowed after 12:00' in str(excinfo.value) with freeze_time("2020-01-01 11:01:00"): assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() @@ -601,6 +610,7 @@ def test_rules_time_isbefore_with_tolerance(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Only allowed before 12:10' in str(excinfo.value) with freeze_time("2020-01-01 11:09:00"): assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() @@ -618,6 +628,7 @@ def test_rules_time_isafter_custom_time(event, position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Only allowed after 23:00' in str(excinfo.value) with freeze_time("2020-01-01 22:05:00"): assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() @@ -639,12 +650,108 @@ def test_rules_isafter_subevent(position, clist, event): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'rules' + assert 'Only allowed after 12:00' in str(excinfo.value) with freeze_time("2020-02-01 11:01:00"): assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() perform_checkin(position, clist, {}) +@pytest.mark.django_db +def test_rules_reasoning_prefer_close_date(event, position, clist): + # Ticket is valid starting at a custom time + event.settings.timezone = 'Europe/Berlin' + clist.rules = { + "or": [ + { + "and": [ + {"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T10:00:00.000Z"]}, None]}, + {"isBefore": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T18:00:00.000Z"]}, None]}, + ] + }, + { + "and": [ + {"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-02T10:00:00.000Z"]}, None]}, + {"isBefore": [{"var": "now"}, {"buildTime": ["custom", "2020-01-02T18:00:00.000Z"]}, None]}, + ] + }, + ] + } + clist.save() + with freeze_time("2020-01-01 09:00:00Z"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + assert 'Only allowed after 11:00' in str(excinfo.value) + + with freeze_time("2020-01-01 20:00:00Z"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + assert 'Only allowed before 19:00' in str(excinfo.value) + + with freeze_time("2020-01-02 09:00:00Z"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + assert 'Only allowed after 11:00' in str(excinfo.value) + + with freeze_time("2020-01-03 18:00:00Z"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + assert 'Only allowed before 2020-01-02 19:00' in str(excinfo.value) + + +@pytest.mark.django_db +def test_rules_reasoning_prefer_date_over_product(event, position, clist): + i2 = event.items.create(name="Ticket", default_price=3, admission=True) + clist.rules = { + "or": [ + { + "inList": [ + {"var": "product"}, { + "objectList": [ + {"lookup": ["product", str(i2.pk), "Ticket"]}, + ] + } + ] + }, + { + "and": [ + {"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-02T10:00:00.000Z"]}, None]}, + {"isBefore": [{"var": "now"}, {"buildTime": ["custom", "2020-01-02T18:00:00.000Z"]}, None]}, + ] + } + ] + } + clist.save() + + with freeze_time("2020-01-02 20:00:00Z"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + assert 'Only allowed before 19:00' in str(excinfo.value) + + +@pytest.mark.django_db +def test_rules_reasoning_prefer_number_over_date(event, position, clist): + clist.rules = { + "and": [ + {"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-02T10:00:00.000Z"]}, None]}, + {"isBefore": [{"var": "now"}, {"buildTime": ["custom", "2020-01-02T18:00:00.000Z"]}, None]}, + {">": [{"var": "entries_today"}, 3]} + ] + } + clist.save() + + with freeze_time("2020-01-01 20:00:00Z"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + assert 'Minimum number of entries today exceeded' in str(excinfo.value) + + @pytest.mark.django_db(transaction=True) def test_position_queries(django_assert_num_queries, position, clist): with django_assert_num_queries(11) as captured: diff --git a/src/tests/helpers/test_jsonlogic_boolalg.py b/src/tests/helpers/test_jsonlogic_boolalg.py new file mode 100644 index 0000000000..f77db1ccd3 --- /dev/null +++ b/src/tests/helpers/test_jsonlogic_boolalg.py @@ -0,0 +1,80 @@ +# +# 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 +# . +# + +import pytest + +from pretix.helpers.jsonlogic_boolalg import convert_to_dnf + +params = [ + ( + {"and": [{"var": "a"}, {"eq": [{"var": "a"}, 3]}]}, + {"and": [{"var": "a"}, {"eq": [{"var": "a"}, 3]}]}, + ), + ( + {"or": [{"var": "a"}, {"eq": [{"var": "a"}, 3]}]}, + {"or": [{"var": "a"}, {"eq": [{"var": "a"}, 3]}]}, + ), + ( + {"and": [{"or": ["a", "b"]}, 3]}, + {"or": [{"and": [3, "a"]}, {"and": [3, "b"]}]}, + ), + ( + {"and": [{"or": ["a", "b"]}, {"or": ["c", "d"]}]}, + {"or": [{"and": ["a", "c"]}, {"and": ["a", "d"]}, {"and": ["b", "c"]}, {"and": ["b", "d"]}]}, + ), + ( + {"and": [{"or": ["a", {"and": ["e", "f"]}]}, {"or": ["c", "d"]}]}, + {"or": [{"and": ["a", "c"]}, {"and": ["a", "d"]}, {"and": ["e", "f", "c"]}, {"and": ["e", "f", "d"]}]}, + ), + ( + {"and": [{"or": ["a", {"and": ["e", {"or": ["f", "g"]}]}]}, {"or": ["c", "d"]}]}, + {"or": [{"and": ["a", "c"]}, {"and": ["a", "d"]}, {"and": ["c", "e", "f"]}, {"and": ["c", "e", "g"]}, + {"and": ["d", "e", "f"]}, {"and": ["d", "e", "g"]}]}, + ), + ( + {"and": [{"or": ["a", {"and": ["e", {"or": ["f", {"and": ["g", "h"]}]}]}]}, {"or": ["c", "d"]}]}, + {"or": [{"and": ["a", "c"]}, {"and": ["a", "d"]}, {"and": ["c", "e", "f"]}, {"and": ["c", "e", "g", "h"]}, + {"and": ["d", "e", "f"]}, {"and": ["d", "e", "g", "h"]}]}, + ), +] + + +def compare_ignoring_order(data1, data2): + if isinstance(data1, list) and isinstance(data2, list): + try: + assert set(data1) == set(data2) + except: + print(data1, data2) + assert len(data1) == len(data2) and all(data1.count(i) == data2.count(i) for i in data1) + elif isinstance(data1, dict) and isinstance(data2, dict): + assert set(data1.keys()) == set(data2.keys()) + compare_ignoring_order(list(data1.values()), list(data2.values())) + else: + assert data1 == data2 + + +@pytest.mark.parametrize("logic,expected", params) +def test_convert_to_dnf(logic, expected): + print("orig", logic) + print("resu", convert_to_dnf(logic)) + print("expe", expected) + assert convert_to_dnf(logic) == expected