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 %}