From 0220965ca9b24fd65860281e0daf9d1430a7249d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 12 Jan 2024 17:09:51 +0100 Subject: [PATCH] Check-in: Add rule for number of days with entries since (#3808) --- src/pretix/base/models/checkin.py | 5 +- src/pretix/base/services/checkin.py | 77 +++++++++++++++++- .../pretixcontrol/js/ui/checkinrules.js | 8 ++ .../js/ui/checkinrules/checkin-rule.vue | 6 ++ .../js/ui/checkinrules/viz-node.vue | 6 ++ src/tests/base/test_checkin.py | 79 +++++++++++++++++++ 6 files changed, 175 insertions(+), 6 deletions(-) diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index dfe0ab94da..36b2e7a204 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -280,7 +280,8 @@ class CheckinList(LoggedModel): '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' } allowed_operators = top_level_operators | { - 'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before' + 'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before', 'entries_days_since', + 'entries_days_before', } allowed_vars = { 'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days', @@ -309,7 +310,7 @@ class CheckinList(LoggedModel): raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.') return rules - if operator in ('entries_since', 'entries_before'): + if operator in ('entries_since', 'entries_before', 'entries_days_since', 'entries_days_before'): if len(values) != 1 or "buildTime" not in values[0]: raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.') diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index a55869ed87..4281cfd752 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -202,7 +202,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): 'var': values[0]["var"], 'rhs': values[1:], } - elif "entries_since" in values[0] or "entries_before" in values[0]: + elif any(t in values[0] for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")): _var_explanations[new_var_name] = { 'operator': operator, 'var': values[0], @@ -280,11 +280,13 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): var_weights[vname] = (500, 0) var_texts[vname] = _('Wrong entrance gate') elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday') \ - or (isinstance(var, dict) and ("entries_since" in var or "entries_before" in var)): + or (isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before"))): w = { 'minutes_since_first_entry': 80, 'minutes_since_last_entry': 90, 'entries_days': 100, + 'entries_days_since': 105, + 'entries_days_before': 105, 'entries_since': 110, 'entries_before': 110, 'entries_number': 120, @@ -307,10 +309,12 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): 'entries_today': _('number of entries today'), 'entries_since': _('number of entries since {datetime}'), 'entries_before': _('number of entries before {datetime}'), + 'entries_days_since': _('number of days with an entry since {datetime}'), + 'entries_days_before': _('number of days with an entry before {datetime}'), 'now_isoweekday': _('week day'), } - if isinstance(var, dict) and ("entries_since" in var or "entries_before" in var): + if isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")): varname = list(var.keys())[0] cutoff = _build_time(*var[varname][0]['buildTime'], ev=ev, now_dt=now_dt).astimezone(ev.timezone) if abs(now_dt - cutoff) < timedelta(hours=12): @@ -410,6 +414,8 @@ def _get_logic_environment(ev, rule_data, now_dt): logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol)) logic.add_operation('entries_since', lambda t1: rule_data.entries_since(t1)) logic.add_operation('entries_before', lambda t1: rule_data.entries_before(t1)) + logic.add_operation('entries_days_since', lambda t1: rule_data.entries_days_since(t1)) + logic.add_operation('entries_days_before', lambda t1: rule_data.entries_days_before(t1)) return logic @@ -467,6 +473,32 @@ class LazyRuleVars: self.__cache['entries_before', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__lt=cutoff).count() return self.__cache['entries_before', cutoff] + def entries_days_since(self, cutoff): + tz = self._clist.event.timezone + with override(tz): + if ('entries_days_since', cutoff) not in self.__cache: + self.__cache['entries_days_since', cutoff] = self._position.checkins.filter( + type=Checkin.TYPE_ENTRY, + list=self._clist, + datetime__gte=cutoff + ).annotate( + day=TruncDate('datetime', tzinfo=tz) + ).values('day').distinct().count() + return self.__cache['entries_days_since', cutoff] + + def entries_days_before(self, cutoff): + tz = self._clist.event.timezone + with override(tz): + if ('entries_days_before', cutoff) not in self.__cache: + self.__cache['entries_days_before', cutoff] = self._position.checkins.filter( + type=Checkin.TYPE_ENTRY, + list=self._clist, + datetime__lt=cutoff + ).annotate( + day=TruncDate('datetime', tzinfo=tz) + ).values('day').distinct().count() + return self.__cache['entries_days_before', cutoff] + @cached_property def entries_days(self): tz = self._clist.event.timezone @@ -533,7 +565,8 @@ class SQLLogic: "isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)), "isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)), } - self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'} + self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before', + 'entries_days_since', 'entries_days_before'} def operation_to_expression(self, rule): if not isinstance(rule, dict): @@ -611,6 +644,42 @@ class SQLLogic: Value(0), output_field=IntegerField() ) + elif operator == 'entries_days_since': + tz = self.list.event.timezone + return Coalesce( + Subquery( + Checkin.objects.filter( + position_id=OuterRef('pk'), + type=Checkin.TYPE_ENTRY, + list_id=self.list.pk, + datetime__gte=self.operation_to_expression(values[0]), + ).annotate( + day=TruncDate('datetime', tzinfo=tz) + ).values('position_id').order_by().annotate( + c=Count('day', distinct=True) + ).values('c') + ), + Value(0), + output_field=IntegerField() + ) + elif operator == 'entries_days_before': + tz = self.list.event.timezone + return Coalesce( + Subquery( + Checkin.objects.filter( + position_id=OuterRef('pk'), + type=Checkin.TYPE_ENTRY, + list_id=self.list.pk, + datetime__lt=self.operation_to_expression(values[0]), + ).annotate( + day=TruncDate('datetime', tzinfo=tz) + ).values('position_id').order_by().annotate( + c=Count('day', distinct=True) + ).values('c') + ), + Value(0), + output_field=IntegerField() + ) elif operator == 'var': if values[0] == 'now': return Value(now().astimezone(timezone.utc)) diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js index b54c9ce235..fc5996b48f 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js @@ -129,6 +129,14 @@ $(function () { 'label': gettext('Number of days with a previous entry'), 'type': 'int', }, + 'entries_days_since': { + 'label': gettext('Number of days with a previous entry since'), + 'type': 'int_by_datetime', + }, + 'entries_days_before': { + 'label': gettext('Number of days with a previous entry before'), + 'type': 'int_by_datetime', + }, 'minutes_since_last_entry': { 'label': gettext('Minutes since last entry (-1 on first entry)'), 'type': 'int', diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue index 0d6ea97781..fe519ab56d 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue @@ -93,6 +93,12 @@ if (this.rule[op][0]["entries_before"]) { return "entries_before"; } + if (this.rule[op][0]["entries_days_since"]) { + return "entries_days_since"; + } + if (this.rule[op][0]["entries_days_before"]) { + return "entries_days_before"; + } return this.rule[op][0]["var"]; } else { return null; diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue index cfa25a2452..d576849a02 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue @@ -166,6 +166,12 @@ if (this.node.rule[op][0]["entries_before"]) { return "entries_before"; } + if (this.node.rule[op][0]["entries_days_since"]) { + return "entries_days_since"; + } + if (this.node.rule[op][0]["entries_days_before"]) { + return "entries_days_before"; + } return this.node.rule[op][0]["var"]; } else { return ""; diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index 651d14c917..aca0c5a15a 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -846,6 +846,85 @@ def test_rules_entries_before(event, position, clist): assert 'Minimum number of entries before 23:00 exceeded' in str(excinfo.value) +@pytest.mark.django_db +def test_rules_entries_days_since(event, position, clist): + # Ticket is valid once before X and on one day after X + event.settings.timezone = 'Europe/Berlin' + clist.allow_multiple_entries = True + clist.rules = { + "or": [ + {"<=": [{"var": "entries_number"}, 0]}, + {"and": [ + {"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}, 0]}, + {"or": [ + {">": [{"var": "entries_today"}, 0]}, + {"<=": [{"entries_days_since": [{"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}]}, 0]}, + ]} + ]}, + ], + } + clist.save() + with freeze_time("2020-01-01 22:00:00+01:00"): + assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() + perform_checkin(position, clist, {}) + + 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 exceeded' in str(excinfo.value) + + with freeze_time("2020-01-02 23:10:00+01:00"): + assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() + perform_checkin(position, clist, {}) + + perform_checkin(position, clist, {}) + assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() + + with freeze_time("2020-01-03 23:10: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 'Maximum number of days with an entry since 2020-01-01 23:00 exceeded' in str(excinfo.value) + + +@pytest.mark.django_db +def test_rules_entries_days_before(event, position, clist): + # Ticket is valid after 23:00 only if people already showed up on two days before + event.settings.timezone = 'Europe/Berlin' + clist.allow_multiple_entries = True + clist.rules = { + "or": [ + {"isBefore": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}, 0]}, + {"and": [ + {"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}, 0]}, + {">=": [{"entries_days_before": [{"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}]}, 2]}, + ]}, + ], + } + clist.save() + + with freeze_time("2019-12-30 22:00:00+01:00"): + assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() + perform_checkin(position, clist, {}) + + with freeze_time("2020-01-02 23:10: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 'Minimum number of days with an entry before 2020-01-01 23:00 exceeded.' in str(excinfo.value) + + with freeze_time("2019-12-31 22:00:00+01:00"): + assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() + perform_checkin(position, clist, {}) + + with freeze_time("2020-01-02 23:10: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_time_isafter_tolerance(event, position, clist): # Ticket is valid starting 10 minutes before admission time