Check-in rules: Add now_isoweekday and minutes_since_last_entry (#2577)

This commit is contained in:
Raphael Michel
2022-04-21 17:17:59 +02:00
committed by GitHub
parent 0aff74afc6
commit 316081658a
5 changed files with 222 additions and 7 deletions

View File

@@ -188,6 +188,7 @@ class CheckinList(LoggedModel):
# * in pretix.helpers.jsonlogic_boolalg # * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js # * in checkinrules.js
# * in libpretixsync # * in libpretixsync
# * in pretixscan-ios (in the future)
top_level_operators = { top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
} }
@@ -195,7 +196,8 @@ class CheckinList(LoggedModel):
'buildTime', 'objectList', 'lookup', 'var', 'buildTime', 'objectList', 'lookup', 'var',
} }
allowed_vars = { allowed_vars = {
'product', 'variation', 'now', 'entries_number', 'entries_today', 'entries_days' 'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
'minutes_since_last_entry', 'minutes_since_first_entry',
} }
if not rules or not isinstance(rules, dict): if not rules or not isinstance(rules, dict):
return rules return rules

View File

@@ -41,8 +41,8 @@ import pytz
from django.core.files import File from django.core.files import File
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import ( from django.db.models import (
BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q, BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min,
Subquery, Value, OuterRef, Q, Subquery, Value,
) )
from django.db.models.functions import Coalesce, TruncDate from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver from django.dispatch import receiver
@@ -60,7 +60,7 @@ from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import ( from pretix.helpers.jsonlogic_query import (
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan, Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
tolerance, MinutesSince, tolerance,
) )
@@ -210,19 +210,60 @@ def _logic_explain(rules, ev, rule_data):
elif var == 'product' or var == 'variation': elif var == 'product' or var == 'variation':
var_weights[vname] = (1000, 0) var_weights[vname] = (1000, 0)
var_texts[vname] = _('Ticket type not allowed') var_texts[vname] = _('Ticket type not allowed')
elif var in ('entries_number', 'entries_today', 'entries_days'): elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday'):
w = { w = {
'minutes_since_first_entry': 80,
'minutes_since_last_entry': 90,
'entries_days': 100, 'entries_days': 100,
'entries_number': 120, 'entries_number': 120,
'entries_today': 140, 'entries_today': 140,
'now_isoweekday': 210,
}
operator_weights = {
'==': 2,
'<': 1,
'<=': 1,
'>': 1,
'>=': 1,
'!=': 3,
} }
l = { l = {
'minutes_since_last_entry': _('time since last entry'),
'minutes_since_first_entry': _('time since first entry'),
'entries_days': _('number of days with an entry'), 'entries_days': _('number of days with an entry'),
'entries_number': _('number of entries'), 'entries_number': _('number of entries'),
'entries_today': _('number of entries today'), 'entries_today': _('number of entries today'),
'now_isoweekday': _('week day'),
} }
compare_to = rhs[0] compare_to = rhs[0]
var_weights[vname] = (w[var], abs(compare_to - rule_data[var])) penalty = 0
if var in ('minutes_since_last_entry', 'minutes_since_first_entry'):
is_comparison_to_minus_one = (
(operator == '<' and compare_to <= 0) or
(operator == '<=' and compare_to < 0) or
(operator == '>=' and compare_to < 0) or
(operator == '>' and compare_to <= 0) or
(operator == '==' and compare_to == -1) or
(operator == '!=' and compare_to == -1)
)
if is_comparison_to_minus_one:
# These are "technical" comparisons without real meaning, we don't want to show them.
penalty = 1000
var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - rule_data[var]))
if var == 'now_isoweekday':
compare_to = {
1: _('Monday'),
2: _('Tuesday'),
3: _('Wednesday'),
4: _('Thursday'),
5: _('Friday'),
6: _('Saturday'),
7: _('Sunday'),
}.get(compare_to, compare_to)
if operator == '==': if operator == '==':
var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to) var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to)
elif operator in ('<', '<='): elif operator in ('<', '<='):
@@ -231,6 +272,7 @@ def _logic_explain(rules, ev, rule_data):
var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var]) var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var])
elif operator == '!=': elif operator == '!=':
var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to) var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to)
else: else:
raise ValueError(f'Unknown variable {var}') raise ValueError(f'Unknown variable {var}')
@@ -289,6 +331,11 @@ class LazyRuleVars:
def now(self): def now(self):
return self._dt return self._dt
@property
def now_isoweekday(self):
tz = self._clist.event.timezone
return self._dt.astimezone(tz).isoweekday()
@property @property
def product(self): def product(self):
return self._position.item_id return self._position.item_id
@@ -315,6 +362,30 @@ class LazyRuleVars:
day=TruncDate('datetime', tzinfo=tz) day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count() ).values('day').distinct().count()
@cached_property
def minutes_since_last_entry(self):
tz = self._clist.event.timezone
with override(tz):
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').last()
if last_entry is None:
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
return (now() - last_entry.datetime).total_seconds() // 60
@cached_property
def minutes_since_first_entry(self):
tz = self._clist.event.timezone
with override(tz):
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').first()
if last_entry is None:
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
return (now() - last_entry.datetime).total_seconds() // 60
class SQLLogic: class SQLLogic:
""" """
@@ -399,6 +470,8 @@ class SQLLogic:
elif operator == 'var': elif operator == 'var':
if values[0] == 'now': if values[0] == 'now':
return Value(now().astimezone(pytz.UTC)) return Value(now().astimezone(pytz.UTC))
elif values[0] == 'now_isoweekday':
return Value(now().astimezone(self.list.event.timezone).isoweekday())
elif values[0] == 'product': elif values[0] == 'product':
return F('item_id') return F('item_id')
elif values[0] == 'variation': elif values[0] == 'variation':
@@ -450,6 +523,38 @@ class SQLLogic:
Value(0), Value(0),
output_field=IntegerField() output_field=IntegerField()
) )
elif values[0] == 'minutes_since_last_entry':
sq_last_entry = Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).values('position_id').order_by().annotate(
m=Max('datetime')
).values('m')
)
return Coalesce(
MinutesSince(sq_last_entry),
Value(-1),
output_field=IntegerField()
)
elif values[0] == 'minutes_since_first_entry':
sq_last_entry = Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).values('position_id').order_by().annotate(
m=Min('datetime')
).values('m')
)
return Coalesce(
MinutesSince(sq_last_entry),
Value(-1),
output_field=IntegerField()
)
else: else:
raise ValueError(f'Unknown operator {operator}') raise ValueError(f'Unknown operator {operator}')

View File

@@ -22,7 +22,10 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from django.db.models import Func, Value from django.db import connection
from django.db.models import Func, IntegerField, Value
from django.db.models.functions import Cast
from django.utils.timezone import now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,3 +81,21 @@ def tolerance(b, tol=None, sign=1):
if tol: if tol:
return b + timedelta(minutes=sign * float(tol)) return b + timedelta(minutes=sign * float(tol))
return b return b
class PostgresIntervalToEpoch(Func):
arity = 1
def as_sql(self, compiler, connection, function=None, template=None, arg_joiner=None, **extra_context):
lhs, lhs_params = compiler.compile(self.source_expressions[0])
return '(EXTRACT(epoch FROM (%s))::int)' % lhs, lhs_params
def MinutesSince(dt):
if '.postgresql' in connection.settings_dict['ENGINE']:
return PostgresIntervalToEpoch(Value(now()) - dt) / 60
else:
# date diffs on MySQL and SQLite are implemented in microseconds by django, so we just cast and convert
# see https://github.com/django/django/blob/d436554861b9b818994276d7bf110bf03aa565f5/django/db/backends/sqlite3/_functions.py#L291
# and https://github.com/django/django/blob/7119f40c9881666b6f9b5cf7df09ee1d21cc8344/django/db/backends/mysql/operations.py#L345
return Cast(Value(now()) - dt, IntegerField()) / 1_000_000 / 60

View File

@@ -67,6 +67,10 @@ $(document).ready(function () {
'label': gettext('Current date and time'), 'label': gettext('Current date and time'),
'type': 'datetime', 'type': 'datetime',
}, },
'now_isoweekday': {
'label': gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
'type': 'int',
},
'entries_number': { 'entries_number': {
'label': gettext('Number of previous entries'), 'label': gettext('Number of previous entries'),
'type': 'int', 'type': 'int',
@@ -79,6 +83,14 @@ $(document).ready(function () {
'label': gettext('Number of days with a previous entry'), 'label': gettext('Number of days with a previous entry'),
'type': 'int', 'type': 'int',
}, },
'minutes_since_last_entry': {
'label': gettext('Minutes since last entry (-1 on first entry)'),
'type': 'int',
},
'minutes_since_first_entry': {
'label': gettext('Minutes since first entry (-1 on first entry)'),
'type': 'int',
},
}; };
Vue.component('checkin-rule', CheckinRule.default); Vue.component('checkin-rule', CheckinRule.default);

View File

@@ -495,6 +495,63 @@ def test_rules_scan_number(position, clist):
assert 'Maximum number of entries' in str(excinfo.value) assert 'Maximum number of entries' in str(excinfo.value)
@pytest.mark.django_db
def test_rules_scan_minutes_since_last(position, clist):
# Ticket is valid unlimited times, but you always need to wait 3 hours
clist.allow_multiple_entries = True
clist.rules = {"or": [{"<=": [{"var": "minutes_since_last_entry"}, -1]}, {">": [{"var": "minutes_since_last_entry"}, 60 * 3]}]}
clist.save()
with freeze_time("2020-01-01 10:00:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
with freeze_time("2020-01-01 12:55: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 'Minimum time since last entry' in str(excinfo.value)
with freeze_time("2020-01-01 13:01:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
with freeze_time("2020-01-01 15:55: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 'Minimum time since last entry' in str(excinfo.value)
with freeze_time("2020-01-01 16:02: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_scan_minutes_since_fist(position, clist):
# Ticket is valid unlimited times, but you always need to wait 3 hours
clist.allow_multiple_entries = True
clist.rules = {"or": [{"<=": [{"var": "minutes_since_first_entry"}, -1]}, {"<": [{"var": "minutes_since_first_entry"}, 60 * 3]}]}
clist.save()
with freeze_time("2020-01-01 10:00:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
with freeze_time("2020-01-01 12:55:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
with freeze_time("2020-01-01 13:01: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 time since first entry' in str(excinfo.value)
@pytest.mark.django_db @pytest.mark.django_db
def test_rules_scan_today(event, position, clist): def test_rules_scan_today(event, position, clist):
# Ticket is valid three times per day # Ticket is valid three times per day
@@ -683,6 +740,24 @@ def test_rules_isafter_subevent(position, clist, event):
perform_checkin(position, clist, {}) perform_checkin(position, clist, {})
@pytest.mark.django_db
def test_rules_time_isoweekday(event, position, clist):
# Ticket is valid starting at a custom time
event.settings.timezone = 'Europe/Berlin'
clist.rules = {"==": [{"var": "now_isoweekday"}, 6]}
clist.save()
with freeze_time("2022-04-06 21:55:00+01: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 'week day is not Saturday' in str(excinfo.value)
with freeze_time("2022-04-09 22:05:00+01:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
@pytest.mark.django_db @pytest.mark.django_db
def test_rules_reasoning_prefer_close_date(event, position, clist): def test_rules_reasoning_prefer_close_date(event, position, clist):
# Ticket is valid starting at a custom time # Ticket is valid starting at a custom time