mirror of
https://github.com/pretix/pretix.git
synced 2026-05-11 16:13:59 +00:00
Check-in rules: Add now_isoweekday and minutes_since_last_entry (#2577)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user