diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 36b2e7a204..0590d6b102 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -285,7 +285,7 @@ class CheckinList(LoggedModel): } allowed_vars = { 'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days', - 'minutes_since_last_entry', 'minutes_since_first_entry', 'gate', + 'minutes_since_last_entry', 'minutes_since_first_entry', 'gate', 'entry_status', } 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 4281cfd752..661160922a 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -42,8 +42,8 @@ from dateutil.tz import datetime_exists from django.core.files import File from django.db import IntegrityError, transaction from django.db.models import ( - BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min, - OuterRef, Q, Subquery, Value, + BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min, + OuterRef, Q, Subquery, TextField, Value, When, ) from django.db.models.functions import Coalesce, TruncDate from django.dispatch import receiver @@ -273,6 +273,14 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): 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 == 'entry_status': + var_weights[vname] = (20, 0) + if operator == '==' and rhs[0] == 'present': + var_texts[vname] = _('Attendee is checked out') + elif operator == '==' and rhs[0] == 'absent': + var_texts[vname] = _('Attendee is already checked in') + else: + var_texts[vname] = f'{var} not {operator} {rhs}' elif var == 'product' or var == 'variation': var_weights[vname] = (1000, 0) var_texts[vname] = _('Ticket type not allowed') @@ -507,6 +515,13 @@ class LazyRuleVars: day=TruncDate('datetime', tzinfo=tz) ).values('day').distinct().count() + @cached_property + def entry_status(self): + last_checkin = self._position.checkins.filter(list=self._clist).order_by('datetime').last() + if not last_checkin or last_checkin.type == Checkin.TYPE_EXIT: + return "absent" + return "present" + @cached_property def minutes_since_last_entry(self): tz = self._clist.event.timezone @@ -569,6 +584,8 @@ class SQLLogic: 'entries_days_since', 'entries_days_before'} def operation_to_expression(self, rule): + if isinstance(rule, str): + return Value(rule) if not isinstance(rule, dict): return rule @@ -770,6 +787,25 @@ class SQLLogic: Value(-1), output_field=IntegerField() ) + elif values[0] == 'entry_status': + sq_last_checkin = Subquery( + Checkin.objects.filter( + position_id=OuterRef('pk'), + list_id=self.list.pk, + ).order_by('-datetime').values('type')[:1] + ) + + return Case( + When( + condition=Equal( + sq_last_checkin, + Value(Checkin.TYPE_ENTRY) + ), + then=Value("present"), + ), + default=Value("absent"), + output_field=TextField() + ) else: raise ValueError(f'Unknown operator {operator}') diff --git a/src/pretix/helpers/jsonlogic_query.py b/src/pretix/helpers/jsonlogic_query.py index 867546c7ec..8206d7d136 100644 --- a/src/pretix/helpers/jsonlogic_query.py +++ b/src/pretix/helpers/jsonlogic_query.py @@ -34,6 +34,7 @@ class Equal(Func): arg_joiner = ' = ' arity = 2 function = '' + conditional = True class GreaterThan(Func): diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js index fc5996b48f..4d6ec8920f 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js @@ -35,6 +35,12 @@ $(function () { 'cardinality': 2, }, }, + 'enum_entry_status': { + '==': { + 'label': gettext('='), + 'cardinality': 2, + }, + }, 'int_by_datetime': { '<': { 'label': '<', @@ -109,6 +115,10 @@ $(function () { 'label': gettext('Current day of the week (1 = Monday, 7 = Sunday)'), 'type': 'int', }, + 'entry_status': { + 'label': gettext('Current entry status'), + 'type': 'enum_entry_status', + }, 'entries_number': { 'label': gettext('Number of previous entries'), 'type': 'int', @@ -180,6 +190,8 @@ $(function () { condition_add: gettext('Add condition'), minutes: gettext('minutes'), duplicate: gettext('Duplicate'), + status_present: pgettext('entry_status', 'present'), + status_absent: pgettext('entry_status', 'absent'), }, hasRules: false, }; 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 fe519ab56d..707138edd0 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue @@ -56,6 +56,11 @@ +
@@ -312,6 +317,13 @@ this.$set(this.rule[this.operator], 1, products); } }, + setRightOperandEnum: function (event) { + if (this.rule[this.operator].length === 1) { + this.rule[this.operator].push(event.target.value); + } else { + this.$set(this.rule[this.operator], 1, event.target.value); + } + }, addOperand: function () { this.rule[this.operator].push({"": []}); }, 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 d576849a02..27911f3c89 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue @@ -75,6 +75,17 @@ {{ rightoperand.objectList.map((o) => o.lookup[2]).join(", ") }} + + + {{ vardata.label }} + + ({{varresult}}) + +
+ + {{ op.label }} {{ rightoperand }} + +
diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index aca0c5a15a..221dfb43a9 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -746,6 +746,33 @@ def test_rules_scan_days(event, position, clist): assert 'Maximum number of days with an entry exceeded.' in str(excinfo.value) +@pytest.mark.django_db +def test_rules_scan_entry_status(position, clist): + # Ticket is valid three times + clist.allow_multiple_entries = True + clist.rules = { + "or": [ + {"==": [{"var": "entry_status"}, "absent"]}, + {"<": [{"var": "entries_number"}, 1]} + ] + } + clist.save() + + 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 "Attendee is already checked in." in str(excinfo.value) + + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) + + assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() + perform_checkin(position, clist, {}) + + @pytest.mark.django_db def test_rules_entries_since(event, position, clist): # Ticket is valid once before X and once after X