From 316081658abea5a1d62561eb1f2214f02d470feb Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 21 Apr 2022 17:17:59 +0200 Subject: [PATCH] Check-in rules: Add now_isoweekday and minutes_since_last_entry (#2577) --- src/pretix/base/models/checkin.py | 4 +- src/pretix/base/services/checkin.py | 115 +++++++++++++++++- src/pretix/helpers/jsonlogic_query.py | 23 +++- .../pretixcontrol/js/ui/checkinrules.js | 12 ++ src/tests/base/test_checkin.py | 75 ++++++++++++ 5 files changed, 222 insertions(+), 7 deletions(-) diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index b49084a226..8affd3fbb6 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -188,6 +188,7 @@ class CheckinList(LoggedModel): # * in pretix.helpers.jsonlogic_boolalg # * in checkinrules.js # * in libpretixsync + # * in pretixscan-ios (in the future) top_level_operators = { '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' } @@ -195,7 +196,8 @@ class CheckinList(LoggedModel): 'buildTime', 'objectList', 'lookup', 'var', } 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): return rules diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 92fba0c7db..175533db78 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -41,8 +41,8 @@ import pytz from django.core.files import File from django.db import IntegrityError, transaction from django.db.models import ( - BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q, - Subquery, Value, + BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min, + OuterRef, Q, Subquery, Value, ) from django.db.models.functions import Coalesce, TruncDate 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_query import ( 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': var_weights[vname] = (1000, 0) 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 = { + 'minutes_since_first_entry': 80, + 'minutes_since_last_entry': 90, 'entries_days': 100, 'entries_number': 120, 'entries_today': 140, + 'now_isoweekday': 210, + } + operator_weights = { + '==': 2, + '<': 1, + '<=': 1, + '>': 1, + '>=': 1, + '!=': 3, } 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_number': _('number of entries'), 'entries_today': _('number of entries today'), + 'now_isoweekday': _('week day'), } 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 == '==': var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to) elif operator in ('<', '<='): @@ -231,6 +272,7 @@ def _logic_explain(rules, ev, rule_data): 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}') @@ -289,6 +331,11 @@ class LazyRuleVars: def now(self): return self._dt + @property + def now_isoweekday(self): + tz = self._clist.event.timezone + return self._dt.astimezone(tz).isoweekday() + @property def product(self): return self._position.item_id @@ -315,6 +362,30 @@ class LazyRuleVars: day=TruncDate('datetime', tzinfo=tz) ).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: """ @@ -399,6 +470,8 @@ class SQLLogic: elif operator == 'var': if values[0] == 'now': return Value(now().astimezone(pytz.UTC)) + elif values[0] == 'now_isoweekday': + return Value(now().astimezone(self.list.event.timezone).isoweekday()) elif values[0] == 'product': return F('item_id') elif values[0] == 'variation': @@ -450,6 +523,38 @@ class SQLLogic: Value(0), 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: raise ValueError(f'Unknown operator {operator}') diff --git a/src/pretix/helpers/jsonlogic_query.py b/src/pretix/helpers/jsonlogic_query.py index 081838719f..b5ca491f74 100644 --- a/src/pretix/helpers/jsonlogic_query.py +++ b/src/pretix/helpers/jsonlogic_query.py @@ -22,7 +22,10 @@ import logging 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__) @@ -78,3 +81,21 @@ def tolerance(b, tol=None, sign=1): if tol: return b + timedelta(minutes=sign * float(tol)) 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 diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js index 45a8114732..9ea387192e 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js @@ -67,6 +67,10 @@ $(document).ready(function () { 'label': gettext('Current date and time'), 'type': 'datetime', }, + 'now_isoweekday': { + 'label': gettext('Current day of the week (1 = Monday, 7 = Sunday)'), + 'type': 'int', + }, 'entries_number': { 'label': gettext('Number of previous entries'), 'type': 'int', @@ -79,6 +83,14 @@ $(document).ready(function () { 'label': gettext('Number of days with a previous entry'), '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); diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index dbaaa29189..50ce07dd70 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -495,6 +495,63 @@ def test_rules_scan_number(position, clist): 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 def test_rules_scan_today(event, position, clist): # Ticket is valid three times per day @@ -683,6 +740,24 @@ def test_rules_isafter_subevent(position, clist, event): 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 def test_rules_reasoning_prefer_close_date(event, position, clist): # Ticket is valid starting at a custom time