forked from CGM_Public/pretix_original
Check-in rules: Make logic results understandable (#2050)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
90
src/pretix/helpers/jsonlogic_boolalg.py
Normal file
90
src/pretix/helpers/jsonlogic_boolalg.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
80
src/tests/helpers/test_jsonlogic_boolalg.py
Normal file
80
src/tests/helpers/test_jsonlogic_boolalg.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user