diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index ada7cabea5..1837e78ab0 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -278,6 +278,7 @@ with scopes_disabled(): def __init__(self, *args, **kwargs): self.checkinlist = kwargs.pop('checkinlist') + self.gate = kwargs.pop('gate') super().__init__(*args, **kwargs) def has_checkin_qs(self, queryset, name, value): @@ -287,7 +288,7 @@ with scopes_disabled(): if not self.checkinlist.rules: return queryset return queryset.filter( - SQLLogic(self.checkinlist).apply(self.checkinlist.rules) + SQLLogic(self.checkinlist, self.gate).apply(self.checkinlist.rules) ).filter( Q(valid_from__isnull=True) | Q(valid_from__lte=now()), Q(valid_until__isnull=True) | Q(valid_until__gte=now()), @@ -409,7 +410,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, - source_type='barcode', legacy_url_support=False, simulate=False): + source_type='barcode', legacy_url_support=False, simulate=False, gate=None): if not checkinlists: raise ValidationError('No check-in list passed.') @@ -417,7 +418,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, prefetch_related_objects([cl for cl in checkinlists if not cl.all_products], 'limit_products') device = auth if isinstance(auth, Device) else None - gate = auth.gate if isinstance(auth, Device) else None + gate = gate or (auth.gate if isinstance(auth, Device) else None) context = { 'request': request, @@ -672,6 +673,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, raw_source_type=source_type, from_revoked_secret=from_revoked_secret, simulate=simulate, + gate=gate, ) except RequiredQuestionsError as e: return Response({ @@ -770,6 +772,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): def get_filterset_kwargs(self): return { 'checkinlist': self.checkinlist, + 'gate': self.request.auth.gate if isinstance(self.request.auth, Device) else None, } @cached_property diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 8651e3020c..ceb3e2f6dd 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -265,16 +265,16 @@ class CheckinList(LoggedModel): # * in pretix.helpers.jsonlogic_boolalg # * in checkinrules.js # * in libpretixsync - # * in pretixscan-ios (in the future) + # * in pretixscan-ios top_level_operators = { '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' } allowed_operators = top_level_operators | { - 'buildTime', 'objectList', 'lookup', 'var', + 'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before' } allowed_vars = { 'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days', - 'minutes_since_last_entry', 'minutes_since_first_entry', + 'minutes_since_last_entry', 'minutes_since_first_entry', 'gate', } if not rules or not isinstance(rules, dict): return rules @@ -299,6 +299,10 @@ class CheckinList(LoggedModel): raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.') return rules + if operator in ('entries_since', 'entries_before'): + if len(values) != 1 or "buildTime" not in values[0]: + raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.') + if operator in ('or', 'and') and seen_nonbool: raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.') diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 5568973651..1264fc93df 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -87,7 +87,7 @@ def _build_time(t=None, value=None, ev=None, now_dt=None): def _logic_annotate_for_graphic_explain(rules, ev, rule_data, now_dt): - logic_environment = _get_logic_environment(ev, now_dt) + logic_environment = _get_logic_environment(ev, rule_data, now_dt) event = ev if isinstance(ev, Event) else ev.event def _evaluate_inners(r): @@ -112,6 +112,8 @@ def _logic_annotate_for_graphic_explain(rules, ev, rule_data, now_dt): val = str(event.items.get(pk=val)) elif var == "variation": val = str(ItemVariation.objects.get(item__event=event, pk=val)) + elif var == "gate": + val = str(event.organizer.gates.get(pk=val)) elif isinstance(val, datetime): val = date_format(val.astimezone(ev.timezone), "SHORT_DATETIME_FORMAT") return {"var": var, "__result": val} @@ -152,7 +154,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): get in before 17:00". In the middle of the night it would switch to "You can only get in after 09:00". """ now_dt = now_dt or now() - logic_environment = _get_logic_environment(ev, now_dt) + logic_environment = _get_logic_environment(ev, rule_data, now_dt) _var_values = {'False': False, 'True': True} _var_explanations = {} @@ -174,15 +176,22 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): _var_values[new_var_name] = result if not result: # Operator returned false, let's dig deeper - if "var" not in values[0]: + if "var" in values[0]: + if isinstance(values[0]["var"], list): + values[0]["var"] = values[0]["var"][0] + _var_explanations[new_var_name] = { + 'operator': operator, + 'var': values[0]["var"], + 'rhs': values[1:], + } + elif "entries_since" in values[0] or "entries_before" in values[0]: + _var_explanations[new_var_name] = { + 'operator': operator, + 'var': values[0], + 'rhs': values[1:], + } + else: raise ValueError("Binary operators should be normalized to have a variable on their left-hand side") - if isinstance(values[0]["var"], list): - values[0]["var"] = values[0]["var"][0] - _var_explanations[new_var_name] = { - 'operator': operator, - 'var': values[0]["var"], - 'rhs': values[1:], - } return {'var': new_var_name} try: rules = _evaluate_inners(rules) @@ -249,11 +258,17 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): 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', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday'): + elif var == 'gate': + var_weights[vname] = (10, 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)): w = { 'minutes_since_first_entry': 80, 'minutes_since_last_entry': 90, 'entries_days': 100, + 'entries_since': 110, + 'entries_before': 110, 'entries_number': 120, 'entries_today': 140, 'now_isoweekday': 210, @@ -272,8 +287,24 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): 'entries_days': _('number of days with an entry'), 'entries_number': _('number of entries'), 'entries_today': _('number of entries today'), + 'entries_since': _('number of entries since {datetime}'), + 'entries_before': _('number of entries before {datetime}'), 'now_isoweekday': _('week day'), } + + if isinstance(var, dict) and ("entries_since" in var or "entries_before" in var): + 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): + compare_to_text = date_format(cutoff, 'TIME_FORMAT') + else: + compare_to_text = date_format(cutoff, 'SHORT_DATETIME_FORMAT') + l[varname] = str(l[varname].format(datetime=compare_to_text)) + var = varname + var_result = getattr(rule_data, var)(cutoff) + else: + var_result = rule_data[var] + compare_to = rhs[0] penalty = 0 @@ -290,7 +321,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): # 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])) + var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - var_result)) if var == 'now_isoweekday': compare_to = { @@ -337,12 +368,14 @@ def _logic_explain(rules, ev, rule_data, now_dt=None): return ', '.join(var_texts[v] for v in paths_with_min_weight[0] if not _var_values[v]) -def _get_logic_environment(ev, now_dt): +def _get_logic_environment(ev, rule_data, now_dt): # Every change to our supported JSON logic must be done # * in pretix.base.services.checkin # * in pretix.base.models.checkin + # * in pretix.helpers.jsonlogic_boolalg # * in checkinrules.js # * in libpretixsync + # * in pretixscan-ios def is_before(t1, t2, tolerance=None): if tolerance: @@ -357,14 +390,18 @@ def _get_logic_environment(ev, now_dt): logic.add_operation('buildTime', partial(_build_time, ev=ev, now_dt=now_dt)) logic.add_operation('isBefore', is_before) 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)) return logic class LazyRuleVars: - def __init__(self, position, clist, dt): + def __init__(self, position, clist, dt, gate): self._position = position self._clist = clist self._dt = dt + self._gate = gate + self.__cache = {} def __getitem__(self, item): if item[0] != '_' and hasattr(self, item): @@ -380,6 +417,10 @@ class LazyRuleVars: tz = self._clist.event.timezone return self._dt.astimezone(tz).isoweekday() + @property + def gate(self): + return self._gate.pk if self._gate else None + @property def product(self): return self._position.item_id @@ -398,6 +439,16 @@ class LazyRuleVars: midnight = self._dt.astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0) return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=midnight).count() + def entries_since(self, cutoff): + if ('entries_since', cutoff) not in self.__cache: + self.__cache['entries_since', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=cutoff).count() + return self.__cache['entries_since', cutoff] + + def entries_before(self, cutoff): + if ('entries_before', cutoff) not in self.__cache: + 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] + @cached_property def entries_days(self): tz = self._clist.event.timezone @@ -446,8 +497,9 @@ class SQLLogic: * Comparison operators (==, !=, …) never contain boolean operators (and, or) further down in the stack """ - def __init__(self, list): + def __init__(self, list, gate=None): self.list = list + self.gate = gate self.bool_ops = { "and": lambda *args: reduce(lambda total, arg: total & arg, args) if args else Q(), "or": lambda *args: reduce(lambda total, arg: total | arg, args) if args else Q(), @@ -463,7 +515,7 @@ 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'} + self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'} def operation_to_expression(self, rule): if not isinstance(rule, dict): @@ -511,6 +563,36 @@ class SQLLogic: return [self.operation_to_expression(v) for v in values] elif operator == 'lookup': return int(values[1]) + elif operator == 'entries_since': + 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]), + ).values('position_id').order_by().annotate( + c=Count('*') + ).values('c') + ), + Value(0), + output_field=IntegerField() + ) + elif operator == 'entries_before': + 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]), + ).values('position_id').order_by().annotate( + c=Count('*') + ).values('c') + ), + Value(0), + output_field=IntegerField() + ) elif operator == 'var': if values[0] == 'now': return Value(now().astimezone(timezone.utc)) @@ -520,6 +602,8 @@ class SQLLogic: return F('item_id') elif values[0] == 'variation': return F('variation_id') + elif values[0] == 'gate': + return Value(self.gate.pk if self.gate else None) elif values[0] == 'entries_number': return Coalesce( Subquery( @@ -731,7 +815,8 @@ def _save_answers(op, answers, given_answers): def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, - raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False): + raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False, + gate=None): """ Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is not valid at this time. @@ -746,6 +831,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, :param nonce: A random nonce to prevent race conditions. :param datetime: The datetime of the checkin, defaults to now. :param simulate: If true, the check-in is not saved. + :param gate: The gate the check-in was performed at. """ # !!!!!!!!! @@ -860,8 +946,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, ) if type == Checkin.TYPE_ENTRY and clist.rules: - rule_data = LazyRuleVars(op, clist, dt) - logic = _get_logic_environment(op.subevent or clist.event, now_dt=dt) + rule_data = LazyRuleVars(op, clist, dt, gate=gate) + logic = _get_logic_environment(op.subevent or clist.event, rule_data, now_dt=dt) if not logic.apply(clist.rules, rule_data): if force: force_used = True @@ -885,6 +971,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, device = None if isinstance(auth, Device): device = auth + if not gate: + gate = device.gate last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce', 'position_id')) entry_allowed = ( @@ -908,7 +996,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, list=clist, datetime=dt, device=device, - gate=device.gate if device else None, + gate=gate, nonce=nonce, forced=force and (not entry_allowed or from_revoked_secret or force_used), force_sent=force, diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py index 194fbd55e8..af7b64e07e 100644 --- a/src/pretix/control/forms/checkin.py +++ b/src/pretix/control/forms/checkin.py @@ -32,6 +32,7 @@ from django_scopes.forms import ( from pretix.base.channels import get_all_sales_channels from pretix.base.forms.widgets import SplitDateTimePickerWidget +from pretix.base.models import Gate from pretix.base.models.checkin import Checkin, CheckinList from pretix.control.forms import ItemMultipleChoiceField from pretix.control.forms.widgets import Select2 @@ -201,3 +202,26 @@ class CheckinListSimulatorForm(forms.Form): initial=True, required=False, ) + gate = SafeModelChoiceField( + label=_('Gate'), + empty_label=_('All gates'), + queryset=Gate.objects.none(), + required=False + ) + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + super().__init__(*args, **kwargs) + + self.fields['gate'].queryset = self.event.organizer.gates.all() + self.fields['gate'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:organizer.gates.select2', kwargs={ + 'organizer': self.event.organizer.slug, + }), + 'data-placeholder': _('All gates'), + } + ) + self.fields['gate'].widget.choices = self.fields['gate'].choices + self.fields['gate'].label = _('Gate') diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html index 3a4ccbeee6..c797ac979b 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html @@ -29,6 +29,8 @@ id="product-select2">{% url "control:event.items.select2" event=request.event.slug organizer=request.organizer.slug %} + {% csrf_token %} {% bootstrap_form_errors form %}
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/simulator.html b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html index e7fd624a0c..ec9122311e 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/simulator.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html @@ -31,6 +31,7 @@ {% bootstrap_field form.raw_barcode layout="control" %} {% bootstrap_field form.datetime layout="control" %} {% bootstrap_field form.checkin_type layout="control" %} + {% bootstrap_field form.gate layout="control" %} {% bootstrap_field form.ignore_unpaid layout="control" %} {% bootstrap_field form.questions_supported layout="control" %}
diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index 5de7c0d341..22b30512b5 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -495,6 +495,11 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView): r['Content-Security-Policy'] = 'script-src \'unsafe-eval\'' return r + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.event + return kwargs + def get_initial(self): return { 'datetime': now() @@ -528,12 +533,13 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView): request=self.request, # this is not clean, but we need it in the serializers for URL generation legacy_url_support=False, simulate=True, + gate=form.cleaned_data.get("gate"), ).data if form.cleaned_data["checkin_type"] == Checkin.TYPE_ENTRY and self.list.rules and self.result.get("position")\ and (self.result["status"] in ("ok", "incomplete") or self.result["reason"] == "rules"): op = OrderPosition.objects.get(pk=self.result["position"]["id"]) - rule_data = LazyRuleVars(op, self.list, form.cleaned_data["datetime"]) + rule_data = LazyRuleVars(op, self.list, form.cleaned_data["datetime"], form.cleaned_data.get("gate")) rule_graph = _logic_annotate_for_graphic_explain(self.list.rules, op.subevent or self.list.event, rule_data, form.cleaned_data["datetime"]) self.result["rule_graph"] = rule_graph diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index ea32bf01ea..9090cf252b 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -1010,7 +1010,7 @@ def devices_select2(request, **kwargs): return JsonResponse(doc) -@organizer_permission_required(("can_view_orders", "can_change_organizer_settings")) +@organizer_permission_required(("can_view_orders", "can_change_event_settings", "can_change_organizer_settings")) # This decorator is a bit of a hack since this is not technically an organizer permission, but it does the job here -- # anyone who can see orders for any event can see the check-in log view where this is used as a filter def gate_select2(request, **kwargs): diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js index 15165cd205..b54c9ce235 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js @@ -3,8 +3,10 @@ $(function () { // Every change to our supported JSON logic must be done // * in pretix.base.services.checkin // * in pretix.base.models.checkin + // * in pretix.helpers.jsonlogic_boolalg // * in checkinrules.js // * in libpretixsync + // * in pretixscan-ios 'product': { 'inList': { 'label': gettext('is one of'), @@ -17,6 +19,12 @@ $(function () { 'cardinality': 2, } }, + 'gate': { + 'inList': { + 'label': gettext('is one of'), + 'cardinality': 2, + } + }, 'datetime': { 'isBefore': { 'label': gettext('is before'), @@ -27,6 +35,32 @@ $(function () { 'cardinality': 2, }, }, + 'int_by_datetime': { + '<': { + 'label': '<', + 'cardinality': 2, + }, + '<=': { + 'label': '≤', + 'cardinality': 2, + }, + '>': { + 'label': '>', + 'cardinality': 2, + }, + '>=': { + 'label': '≥', + 'cardinality': 2, + }, + '==': { + 'label': '=', + 'cardinality': 2, + }, + '!=': { + 'label': '≠', + 'cardinality': 2, + }, + }, 'int': { '<': { 'label': '<', @@ -63,6 +97,10 @@ $(function () { 'label': gettext('Product variation'), 'type': 'variation', }, + 'gate': { + 'label': gettext('Gate'), + 'type': 'gate', + }, 'now': { 'label': gettext('Current date and time'), 'type': 'datetime', @@ -79,6 +117,14 @@ $(function () { 'label': gettext('Number of previous entries since midnight'), 'type': 'int', }, + 'entries_since': { + 'label': gettext('Number of previous entries since'), + 'type': 'int_by_datetime', + }, + 'entries_before': { + 'label': gettext('Number of previous entries before'), + 'type': 'int_by_datetime', + }, 'entries_days': { 'label': gettext('Number of days with a previous 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 97efc4a5cc..0d6ea97781 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rule.vue @@ -10,7 +10,7 @@ @@ -21,33 +21,41 @@ - - + - + + + + - - +
@@ -79,6 +87,12 @@ if (op === "and" || op === "or") { return op; } else if (this.rule[op] && this.rule[op][0]) { + if (this.rule[op][0]["entries_since"]) { + return "entries_since"; + } + if (this.rule[op][0]["entries_before"]) { + return "entries_before"; + } return this.rule[op][0]["var"]; } else { return null; @@ -113,7 +127,11 @@ } }, timeType: function () { - if (this.rightoperand && this.rightoperand['buildTime']) { + if (this.vartype === 'int_by_datetime') { + if (this.rule[this.operator][0][this.variable] && this.rule[this.operator][0][this.variable][0]['buildTime']) { + return this.rule[this.operator][0][this.variable][0]['buildTime'][0]; + } + } else if (this.rightoperand && this.rightoperand['buildTime']) { return this.rightoperand['buildTime'][0]; } }, @@ -126,7 +144,11 @@ } }, timeValue: function () { - if (this.rightoperand && this.rightoperand['buildTime']) { + if (this.vartype === 'int_by_datetime') { + if (this.rule[this.operator][0][this.variable][0]['buildTime']) { + return this.rule[this.operator][0][this.variable][0]['buildTime'][1]; + } + } else if (this.rightoperand && this.rightoperand['buildTime']) { return this.rightoperand['buildTime'][1]; } }, @@ -144,6 +166,9 @@ variationSelectURL: function () { return $("#variations-select2").text(); }, + gateSelectURL: function () { + return $("#gates-select2").text(); + }, vars: function () { return this.$root.VARS; }, @@ -161,7 +186,19 @@ this.$delete(this.rule, current_op); } else { if (current_val !== "and" && current_val !== "or" && current_val[0] && this.$root.VARS[event.target.value]['type'] === this.vartype) { - this.$set(this.rule[current_op][0], "var", event.target.value); + if (this.vartype === "int_by_datetime") { + var current_data = this.rule[current_op][0][this.variable]; + var new_lhs = {}; + new_lhs[event.target.value] = JSON.parse(JSON.stringify(current_data)); + this.$set(this.rule[current_op], 0, new_lhs); + } else { + this.$set(this.rule[current_op][0], "var", event.target.value); + } + } else if (this.$root.VARS[event.target.value]['type'] === 'int_by_datetime') { + this.$delete(this.rule, current_op); + var o = {}; + o[event.target.value] = [{"buildTime": [null, null]}] + this.$set(this.rule, "!!", [o]); } else { this.$delete(this.rule, current_op); this.$set(this.rule, "!!", [{"var": event.target.value}]); @@ -192,17 +229,25 @@ var time = { "buildTime": [event.target.value] }; - if (this.rule[this.operator].length === 1) { - this.rule[this.operator].push(time); + if (this.vartype === "int_by_datetime") { + this.$set(this.rule[this.operator][0][this.variable], 0, time); } else { - this.$set(this.rule[this.operator], 1, time); - } - if (event.target.value === "custom") { - this.$set(this.rule[this.operator], 2, 0); + if (this.rule[this.operator].length === 1) { + this.rule[this.operator].push(time); + } else { + this.$set(this.rule[this.operator], 1, time); + } + if (event.target.value === "custom") { + this.$set(this.rule[this.operator], 2, 0); + } } }, setTimeValue: function (val) { - this.$set(this.rule[this.operator][1]["buildTime"], 1, val); + if (this.vartype === "int_by_datetime") { + this.$set(this.rule[this.operator][0][this.variable][0]["buildTime"], 1, val); + } else { + this.$set(this.rule[this.operator][1]["buildTime"], 1, val); + } }, setRightOperandProductList: function (val) { var products = { @@ -242,6 +287,25 @@ this.$set(this.rule[this.operator], 1, products); } }, + setRightOperandGateList: function (val) { + var products = { + "objectList": [] + }; + for (var i = 0; i < val.length; i++) { + products["objectList"].push({ + "lookup": [ + "gate", + val[i].id, + val[i].text + ] + }); + } + if (this.rule[this.operator].length === 1) { + this.rule[this.operator].push(products); + } else { + this.$set(this.rule[this.operator], 1, products); + } + }, 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 3ef8229302..cfa25a2452 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue @@ -19,6 +19,26 @@ {{ op.label }} {{ rightoperand }} + + + {{ vardata.label }} + + {{ df(node.rule[operator][0][variable][0].buildTime[1]) }} + + + {{ tf(node.rule[operator][0][variable][0].buildTime[1]) }} + + + {{ this.$root.texts[node.rule[operator][0][variable][0].buildTime[0]] }} + +
+ + {{varresult}} + + + {{ op.label }} {{ rightoperand }} + +
{{ vardata.label }}
@@ -44,7 +64,9 @@ - {{ vardata.label }} + + + {{ vardata.label }} ({{varresult}}) @@ -138,6 +160,12 @@ variable () { const op = this.operator; if (this.node.rule[op] && this.node.rule[op][0]) { + if (this.node.rule[op][0]["entries_since"]) { + return "entries_since"; + } + if (this.node.rule[op][0]["entries_before"]) { + return "entries_before"; + } return this.node.rule[op][0]["var"]; } else { return ""; @@ -149,6 +177,8 @@ varresult () { const op = this.operator; if (this.node.rule[op] && this.node.rule[op][0]) { + if (typeof this.node.rule[op][0]["__result"] === "undefined") + return null; return this.node.rule[op][0]["__result"]; } else { return ""; diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index f5ba891ac3..19a5389f61 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -562,6 +562,44 @@ def test_rules_variation(item, position, clist): perform_checkin(position, clist, {}) +@pytest.mark.django_db +def test_rules_gate(event, item, position, clist): + g1 = event.organizer.gates.create(name="Gate 1") + g2 = event.organizer.gates.create(name="Gate 2") + clist.rules = { + "inList": [ + {"var": "gate"}, { + "objectList": [ + {"lookup": ["gate", str(g1.pk), "Gate 1"]}, + ] + } + ] + } + clist.save() + with pytest.raises(CheckInError): + perform_checkin(position, clist, {}, gate=None) + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}, gate=g2) + assert not OrderPosition.objects.filter(SQLLogic(clist, gate=g2).apply(clist.rules), pk=position.pk).exists() + assert not OrderPosition.objects.filter(SQLLogic(clist, gate=None).apply(clist.rules), pk=position.pk).exists() + assert excinfo.value.code == 'rules' + assert 'Wrong entrance gate' in str(excinfo.value) + + clist.rules = { + "inList": [ + {"var": "gate"}, { + "objectList": [ + {"lookup": ["gate", str(g1.pk), "Gate 1"]}, + {"lookup": ["gate", str(g2.pk), "Gate 2"]}, + ] + } + ] + } + clist.save() + assert OrderPosition.objects.filter(SQLLogic(clist, gate=g2).apply(clist.rules), pk=position.pk).exists() + perform_checkin(position, clist, {}, gate=g2) + + @pytest.mark.django_db def test_rules_scan_number(position, clist): # Ticket is valid three times @@ -708,6 +746,106 @@ 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_entries_since(event, position, clist): + # Ticket is valid once before X and once 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]}, + {"<=": [{"entries_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-01 23:10: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 since 23:00 exceeded' in str(excinfo.value) + + +@pytest.mark.django_db +def test_rules_entries_since_time_of_day(event, position, clist): + # Ticket is valid daily once before X and once after X + event.settings.timezone = 'Europe/Berlin' + clist.allow_multiple_entries = True + clist.rules = { + "or": [ + {"<=": [{"var": "entries_today"}, 0]}, + {"and": [ + {"isAfter": [{"var": "now"}, {"buildTime": ["customtime", "23:00:00"]}, 0]}, + {"<=": [{"entries_since": [{"buildTime": ["customtime", "23:00:00"]}]}, 0]}, + ]}, + ], + } + clist.save() + + for t in ["2020-01-01 22:00:00+01:00", "2020-01-01 23:01:00+01:00", "2020-01-02 22:00:00+01:00", "2020-01-02 23:01:00+01:00"]: + with freeze_time(t): + 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' + if now().astimezone(event.timezone).hour < 23: + assert 'Maximum number of entries today exceeded' in str(excinfo.value) + else: + assert 'Maximum number of entries since 23:00 exceeded' in str(excinfo.value) + + +@pytest.mark.django_db +def test_rules_entries_before(event, position, clist): + # Ticket is valid after 23:00 only if people already showed up 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_before": [{"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}]}, 1]}, + ]}, + ], + } + 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, {}) + + with freeze_time("2020-01-01 23:10:00+01:00"): + assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists() + perform_checkin(position, clist, {}) + + position.all_checkins.all().delete() + + 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 entries before 23:00 exceeded' in str(excinfo.value) + + @pytest.mark.django_db def test_rules_time_isafter_tolerance(event, position, clist): # Ticket is valid starting 10 minutes before admission time