Check-in rules: New variables (#3521)

This commit is contained in:
Raphael Michel
2023-09-12 09:43:57 +02:00
committed by GitHub
parent c842ea597c
commit eb04fdf4d2
12 changed files with 455 additions and 49 deletions
+6 -3
View File
@@ -278,6 +278,7 @@ with scopes_disabled():
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.checkinlist = kwargs.pop('checkinlist') self.checkinlist = kwargs.pop('checkinlist')
self.gate = kwargs.pop('gate')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
@@ -287,7 +288,7 @@ with scopes_disabled():
if not self.checkinlist.rules: if not self.checkinlist.rules:
return queryset return queryset
return queryset.filter( return queryset.filter(
SQLLogic(self.checkinlist).apply(self.checkinlist.rules) SQLLogic(self.checkinlist, self.gate).apply(self.checkinlist.rules)
).filter( ).filter(
Q(valid_from__isnull=True) | Q(valid_from__lte=now()), Q(valid_from__isnull=True) | Q(valid_from__lte=now()),
Q(valid_until__isnull=True) | Q(valid_until__gte=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, 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, 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: if not checkinlists:
raise ValidationError('No check-in list passed.') 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') prefetch_related_objects([cl for cl in checkinlists if not cl.all_products], 'limit_products')
device = auth if isinstance(auth, Device) else None 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 = { context = {
'request': request, 'request': request,
@@ -672,6 +673,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
raw_source_type=source_type, raw_source_type=source_type,
from_revoked_secret=from_revoked_secret, from_revoked_secret=from_revoked_secret,
simulate=simulate, simulate=simulate,
gate=gate,
) )
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({
@@ -770,6 +772,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
def get_filterset_kwargs(self): def get_filterset_kwargs(self):
return { return {
'checkinlist': self.checkinlist, 'checkinlist': self.checkinlist,
'gate': self.request.auth.gate if isinstance(self.request.auth, Device) else None,
} }
@cached_property @cached_property
+7 -3
View File
@@ -265,16 +265,16 @@ class CheckinList(LoggedModel):
# * in pretix.helpers.jsonlogic_boolalg # * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js # * in checkinrules.js
# * in libpretixsync # * in libpretixsync
# * in pretixscan-ios (in the future) # * in pretixscan-ios
top_level_operators = { top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
} }
allowed_operators = top_level_operators | { allowed_operators = top_level_operators | {
'buildTime', 'objectList', 'lookup', 'var', 'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'
} }
allowed_vars = { allowed_vars = {
'product', 'variation', 'now', 'now_isoweekday', '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', 'minutes_since_last_entry', 'minutes_since_first_entry', 'gate',
} }
if not rules or not isinstance(rules, dict): if not rules or not isinstance(rules, dict):
return rules return rules
@@ -299,6 +299,10 @@ class CheckinList(LoggedModel):
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.') raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
return rules 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: if operator in ('or', 'and') and seen_nonbool:
raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.') raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.')
+108 -20
View File
@@ -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): 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 event = ev if isinstance(ev, Event) else ev.event
def _evaluate_inners(r): 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)) val = str(event.items.get(pk=val))
elif var == "variation": elif var == "variation":
val = str(ItemVariation.objects.get(item__event=event, pk=val)) 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): elif isinstance(val, datetime):
val = date_format(val.astimezone(ev.timezone), "SHORT_DATETIME_FORMAT") val = date_format(val.astimezone(ev.timezone), "SHORT_DATETIME_FORMAT")
return {"var": var, "__result": val} 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". 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() 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_values = {'False': False, 'True': True}
_var_explanations = {} _var_explanations = {}
@@ -174,15 +176,22 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
_var_values[new_var_name] = result _var_values[new_var_name] = result
if not result: if not result:
# Operator returned false, let's dig deeper # 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") 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} return {'var': new_var_name}
try: try:
rules = _evaluate_inners(rules) rules = _evaluate_inners(rules)
@@ -249,11 +258,17 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
elif var == 'product' or var == 'variation': elif var == 'product' or var == 'variation':
var_weights[vname] = (1000, 0) var_weights[vname] = (1000, 0)
var_texts[vname] = _('Ticket type not allowed') 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 = { w = {
'minutes_since_first_entry': 80, 'minutes_since_first_entry': 80,
'minutes_since_last_entry': 90, 'minutes_since_last_entry': 90,
'entries_days': 100, 'entries_days': 100,
'entries_since': 110,
'entries_before': 110,
'entries_number': 120, 'entries_number': 120,
'entries_today': 140, 'entries_today': 140,
'now_isoweekday': 210, '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_days': _('number of days with an entry'),
'entries_number': _('number of entries'), 'entries_number': _('number of entries'),
'entries_today': _('number of entries today'), 'entries_today': _('number of entries today'),
'entries_since': _('number of entries since {datetime}'),
'entries_before': _('number of entries before {datetime}'),
'now_isoweekday': _('week day'), '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] compare_to = rhs[0]
penalty = 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. # These are "technical" comparisons without real meaning, we don't want to show them.
penalty = 1000 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': if var == 'now_isoweekday':
compare_to = { 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]) 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 # Every change to our supported JSON logic must be done
# * in pretix.base.services.checkin # * in pretix.base.services.checkin
# * in pretix.base.models.checkin # * in pretix.base.models.checkin
# * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js # * in checkinrules.js
# * in libpretixsync # * in libpretixsync
# * in pretixscan-ios
def is_before(t1, t2, tolerance=None): def is_before(t1, t2, tolerance=None):
if tolerance: 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('buildTime', partial(_build_time, ev=ev, now_dt=now_dt))
logic.add_operation('isBefore', is_before) logic.add_operation('isBefore', is_before)
logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol)) 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 return logic
class LazyRuleVars: class LazyRuleVars:
def __init__(self, position, clist, dt): def __init__(self, position, clist, dt, gate):
self._position = position self._position = position
self._clist = clist self._clist = clist
self._dt = dt self._dt = dt
self._gate = gate
self.__cache = {}
def __getitem__(self, item): def __getitem__(self, item):
if item[0] != '_' and hasattr(self, item): if item[0] != '_' and hasattr(self, item):
@@ -380,6 +417,10 @@ class LazyRuleVars:
tz = self._clist.event.timezone tz = self._clist.event.timezone
return self._dt.astimezone(tz).isoweekday() return self._dt.astimezone(tz).isoweekday()
@property
def gate(self):
return self._gate.pk if self._gate else None
@property @property
def product(self): def product(self):
return self._position.item_id 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) 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() 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 @cached_property
def entries_days(self): def entries_days(self):
tz = self._clist.event.timezone tz = self._clist.event.timezone
@@ -446,8 +497,9 @@ class SQLLogic:
* Comparison operators (==, !=, …) never contain boolean operators (and, or) further down in the stack * 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.list = list
self.gate = gate
self.bool_ops = { self.bool_ops = {
"and": lambda *args: reduce(lambda total, arg: total & arg, args) if args else Q(), "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(), "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)), "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)), "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): def operation_to_expression(self, rule):
if not isinstance(rule, dict): if not isinstance(rule, dict):
@@ -511,6 +563,36 @@ class SQLLogic:
return [self.operation_to_expression(v) for v in values] return [self.operation_to_expression(v) for v in values]
elif operator == 'lookup': elif operator == 'lookup':
return int(values[1]) 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': elif operator == 'var':
if values[0] == 'now': if values[0] == 'now':
return Value(now().astimezone(timezone.utc)) return Value(now().astimezone(timezone.utc))
@@ -520,6 +602,8 @@ class SQLLogic:
return F('item_id') return F('item_id')
elif values[0] == 'variation': elif values[0] == 'variation':
return F('variation_id') return F('variation_id')
elif values[0] == 'gate':
return Value(self.gate.pk if self.gate else None)
elif values[0] == 'entries_number': elif values[0] == 'entries_number':
return Coalesce( return Coalesce(
Subquery( Subquery(
@@ -731,7 +815,8 @@ def _save_answers(op, answers, given_answers):
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, 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 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. 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 nonce: A random nonce to prevent race conditions.
:param datetime: The datetime of the checkin, defaults to now. :param datetime: The datetime of the checkin, defaults to now.
:param simulate: If true, the check-in is not saved. :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: if type == Checkin.TYPE_ENTRY and clist.rules:
rule_data = LazyRuleVars(op, clist, dt) rule_data = LazyRuleVars(op, clist, dt, gate=gate)
logic = _get_logic_environment(op.subevent or clist.event, now_dt=dt) logic = _get_logic_environment(op.subevent or clist.event, rule_data, now_dt=dt)
if not logic.apply(clist.rules, rule_data): if not logic.apply(clist.rules, rule_data):
if force: if force:
force_used = True force_used = True
@@ -885,6 +971,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
device = None device = None
if isinstance(auth, Device): if isinstance(auth, Device):
device = auth device = auth
if not gate:
gate = device.gate
last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce', 'position_id')) last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce', 'position_id'))
entry_allowed = ( entry_allowed = (
@@ -908,7 +996,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
list=clist, list=clist,
datetime=dt, datetime=dt,
device=device, device=device,
gate=device.gate if device else None, gate=gate,
nonce=nonce, nonce=nonce,
forced=force and (not entry_allowed or from_revoked_secret or force_used), forced=force and (not entry_allowed or from_revoked_secret or force_used),
force_sent=force, force_sent=force,
+24
View File
@@ -32,6 +32,7 @@ from django_scopes.forms import (
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Gate
from pretix.base.models.checkin import Checkin, CheckinList from pretix.base.models.checkin import Checkin, CheckinList
from pretix.control.forms import ItemMultipleChoiceField from pretix.control.forms import ItemMultipleChoiceField
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
@@ -201,3 +202,26 @@ class CheckinListSimulatorForm(forms.Form):
initial=True, initial=True,
required=False, 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')
@@ -29,6 +29,8 @@
id="product-select2">{% url "control:event.items.select2" event=request.event.slug organizer=request.organizer.slug %}</script> id="product-select2">{% url "control:event.items.select2" event=request.event.slug organizer=request.organizer.slug %}</script>
<script type="text/plain" <script type="text/plain"
id="variations-select2">{% url "control:event.items.variations.select2" event=request.event.slug organizer=request.organizer.slug %}</script> id="variations-select2">{% url "control:event.items.variations.select2" event=request.event.slug organizer=request.organizer.slug %}</script>
<script type="text/plain"
id="gates-select2">{% url "control:organizer.gates.select2" organizer=request.organizer.slug %}</script>
{% csrf_token %} {% csrf_token %}
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
<div class="tabbed-form"> <div class="tabbed-form">
@@ -31,6 +31,7 @@
{% bootstrap_field form.raw_barcode layout="control" %} {% bootstrap_field form.raw_barcode layout="control" %}
{% bootstrap_field form.datetime layout="control" %} {% bootstrap_field form.datetime layout="control" %}
{% bootstrap_field form.checkin_type 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.ignore_unpaid layout="control" %}
{% bootstrap_field form.questions_supported layout="control" %} {% bootstrap_field form.questions_supported layout="control" %}
<div class="row"> <div class="row">
+7 -1
View File
@@ -495,6 +495,11 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView):
r['Content-Security-Policy'] = 'script-src \'unsafe-eval\'' r['Content-Security-Policy'] = 'script-src \'unsafe-eval\''
return r return r
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
return kwargs
def get_initial(self): def get_initial(self):
return { return {
'datetime': now() '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 request=self.request, # this is not clean, but we need it in the serializers for URL generation
legacy_url_support=False, legacy_url_support=False,
simulate=True, simulate=True,
gate=form.cleaned_data.get("gate"),
).data ).data
if form.cleaned_data["checkin_type"] == Checkin.TYPE_ENTRY and self.list.rules and self.result.get("position")\ 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"): and (self.result["status"] in ("ok", "incomplete") or self.result["reason"] == "rules"):
op = OrderPosition.objects.get(pk=self.result["position"]["id"]) 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, rule_graph = _logic_annotate_for_graphic_explain(self.list.rules, op.subevent or self.list.event, rule_data,
form.cleaned_data["datetime"]) form.cleaned_data["datetime"])
self.result["rule_graph"] = rule_graph self.result["rule_graph"] = rule_graph
+1 -1
View File
@@ -1010,7 +1010,7 @@ def devices_select2(request, **kwargs):
return JsonResponse(doc) 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 -- # 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 # 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): def gate_select2(request, **kwargs):
@@ -3,8 +3,10 @@ $(function () {
// Every change to our supported JSON logic must be done // Every change to our supported JSON logic must be done
// * in pretix.base.services.checkin // * in pretix.base.services.checkin
// * in pretix.base.models.checkin // * in pretix.base.models.checkin
// * in pretix.helpers.jsonlogic_boolalg
// * in checkinrules.js // * in checkinrules.js
// * in libpretixsync // * in libpretixsync
// * in pretixscan-ios
'product': { 'product': {
'inList': { 'inList': {
'label': gettext('is one of'), 'label': gettext('is one of'),
@@ -17,6 +19,12 @@ $(function () {
'cardinality': 2, 'cardinality': 2,
} }
}, },
'gate': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'datetime': { 'datetime': {
'isBefore': { 'isBefore': {
'label': gettext('is before'), 'label': gettext('is before'),
@@ -27,6 +35,32 @@ $(function () {
'cardinality': 2, 'cardinality': 2,
}, },
}, },
'int_by_datetime': {
'<': {
'label': '<',
'cardinality': 2,
},
'<=': {
'label': '≤',
'cardinality': 2,
},
'>': {
'label': '>',
'cardinality': 2,
},
'>=': {
'label': '≥',
'cardinality': 2,
},
'==': {
'label': '=',
'cardinality': 2,
},
'!=': {
'label': '≠',
'cardinality': 2,
},
},
'int': { 'int': {
'<': { '<': {
'label': '<', 'label': '<',
@@ -63,6 +97,10 @@ $(function () {
'label': gettext('Product variation'), 'label': gettext('Product variation'),
'type': 'variation', 'type': 'variation',
}, },
'gate': {
'label': gettext('Gate'),
'type': 'gate',
},
'now': { 'now': {
'label': gettext('Current date and time'), 'label': gettext('Current date and time'),
'type': 'datetime', 'type': 'datetime',
@@ -79,6 +117,14 @@ $(function () {
'label': gettext('Number of previous entries since midnight'), 'label': gettext('Number of previous entries since midnight'),
'type': 'int', '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': { 'entries_days': {
'label': gettext('Number of days with a previous entry'), 'label': gettext('Number of days with a previous entry'),
'type': 'int', 'type': 'int',
@@ -10,7 +10,7 @@
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithAND">AND <button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithAND">AND
</button> </button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="cutOut" <button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="cutOut"
v-if="operands && operands.length == 1 && (operator === 'or' || operator == 'and')"><span v-if="operands && operands.length === 1 && (operator === 'or' || operator === 'and')"><span
class="fa fa-cut"></span></button> class="fa fa-cut"></span></button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="remove" <button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="remove"
v-if="level > 0"><span class="fa fa-trash"></span></button> v-if="level > 0"><span class="fa fa-trash"></span></button>
@@ -21,33 +21,41 @@
<option v-for="(v, name) in vars" :value="name">{{ v.label }}</option> <option v-for="(v, name) in vars" :value="name">{{ v.label }}</option>
</select> </select>
<select v-bind:value="operator" v-on:input="setOperator" required class="form-control" <select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="operator !== 'or' && operator !== 'and'"> v-if="operator !== 'or' && operator !== 'and' && vartype !== 'int_by_datetime'">
<option></option> <option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option> <option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select> </select>
<select v-bind:value="timeType" v-on:input="setTimeType" required class="form-control" <select v-bind:value="timeType" v-on:input="setTimeType" required class="form-control"
v-if="vartype == 'datetime'"> v-if="vartype === 'datetime' || vartype === 'int_by_datetime'">
<option value="date_from">{{texts.date_from}}</option> <option value="date_from">{{texts.date_from}}</option>
<option value="date_to">{{texts.date_to}}</option> <option value="date_to">{{texts.date_to}}</option>
<option value="date_admission">{{texts.date_admission}}</option> <option value="date_admission">{{texts.date_admission}}</option>
<option value="custom">{{texts.date_custom}}</option> <option value="custom">{{texts.date_custom}}</option>
<option value="customtime">{{texts.date_customtime}}</option> <option value="customtime">{{texts.date_customtime}}</option>
</select> </select>
<datetimefield v-if="vartype == 'datetime' && timeType == 'custom'" :value="timeValue" <datetimefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'custom'" :value="timeValue"
v-on:input="setTimeValue"></datetimefield> v-on:input="setTimeValue"></datetimefield>
<timefield v-if="vartype == 'datetime' && timeType == 'customtime'" :value="timeValue" <timefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'customtime'" :value="timeValue"
v-on:input="setTimeValue"></timefield> v-on:input="setTimeValue"></timefield>
<input class="form-control" required type="number" <input class="form-control" required type="number"
v-if="vartype == 'datetime' && timeType && timeType != 'customtime' && timeType != 'custom'" v-bind:value="timeTolerance" v-if="vartype === 'datetime' && timeType && timeType !== 'customtime' && timeType !== 'custom'" v-bind:value="timeTolerance"
v-on:input="setTimeTolerance" :placeholder="texts.date_tolerance"> v-on:input="setTimeTolerance" :placeholder="texts.date_tolerance">
<input class="form-control" required type="number" v-if="vartype == 'int' && cardinality > 1" <select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="vartype === 'int_by_datetime'">
<option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select>
<input class="form-control" required type="number" v-if="(vartype === 'int' || vartype === 'int_by_datetime') && cardinality > 1"
v-bind:value="rightoperand" v-on:input="setRightOperandNumber"> v-bind:value="rightoperand" v-on:input="setRightOperandNumber">
<lookup-select2 required v-if="vartype == 'product' && operator == 'inList'" :multiple="true" <lookup-select2 required v-if="vartype === 'product' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandProductList" :value="rightoperand" v-on:input="setRightOperandProductList"
:url="productSelectURL"></lookup-select2> :url="productSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype == 'variation' && operator == 'inList'" :multiple="true" <lookup-select2 required v-if="vartype === 'variation' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandVariationList" :value="rightoperand" v-on:input="setRightOperandVariationList"
:url="variationSelectURL"></lookup-select2> :url="variationSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype === 'gate' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandGateList"
:url="gateSelectURL"></lookup-select2>
<div class="checkin-rule-childrules" v-if="operator === 'or' || operator === 'and'"> <div class="checkin-rule-childrules" v-if="operator === 'or' || operator === 'and'">
<div v-for="(op, opi) in operands"> <div v-for="(op, opi) in operands">
<checkin-rule :rule="op" :index="opi" :level="level + 1" v-if="typeof op === 'object'"></checkin-rule> <checkin-rule :rule="op" :index="opi" :level="level + 1" v-if="typeof op === 'object'"></checkin-rule>
@@ -79,6 +87,12 @@
if (op === "and" || op === "or") { if (op === "and" || op === "or") {
return op; return op;
} else if (this.rule[op] && this.rule[op][0]) { } 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"]; return this.rule[op][0]["var"];
} else { } else {
return null; return null;
@@ -113,7 +127,11 @@
} }
}, },
timeType: function () { 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]; return this.rightoperand['buildTime'][0];
} }
}, },
@@ -126,7 +144,11 @@
} }
}, },
timeValue: function () { 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]; return this.rightoperand['buildTime'][1];
} }
}, },
@@ -144,6 +166,9 @@
variationSelectURL: function () { variationSelectURL: function () {
return $("#variations-select2").text(); return $("#variations-select2").text();
}, },
gateSelectURL: function () {
return $("#gates-select2").text();
},
vars: function () { vars: function () {
return this.$root.VARS; return this.$root.VARS;
}, },
@@ -161,7 +186,19 @@
this.$delete(this.rule, current_op); this.$delete(this.rule, current_op);
} else { } else {
if (current_val !== "and" && current_val !== "or" && current_val[0] && this.$root.VARS[event.target.value]['type'] === this.vartype) { 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 { } else {
this.$delete(this.rule, current_op); this.$delete(this.rule, current_op);
this.$set(this.rule, "!!", [{"var": event.target.value}]); this.$set(this.rule, "!!", [{"var": event.target.value}]);
@@ -192,17 +229,25 @@
var time = { var time = {
"buildTime": [event.target.value] "buildTime": [event.target.value]
}; };
if (this.rule[this.operator].length === 1) { if (this.vartype === "int_by_datetime") {
this.rule[this.operator].push(time); this.$set(this.rule[this.operator][0][this.variable], 0, time);
} else { } else {
this.$set(this.rule[this.operator], 1, time); if (this.rule[this.operator].length === 1) {
} this.rule[this.operator].push(time);
if (event.target.value === "custom") { } else {
this.$set(this.rule[this.operator], 2, 0); this.$set(this.rule[this.operator], 1, time);
}
if (event.target.value === "custom") {
this.$set(this.rule[this.operator], 2, 0);
}
} }
}, },
setTimeValue: function (val) { 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) { setRightOperandProductList: function (val) {
var products = { var products = {
@@ -242,6 +287,25 @@
this.$set(this.rule[this.operator], 1, products); 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 () { addOperand: function () {
this.rule[this.operator].push({"": []}); this.rule[this.operator].push({"": []});
}, },
@@ -19,6 +19,26 @@
{{ op.label }} {{ rightoperand }} {{ op.label }} {{ rightoperand }}
</strong> </strong>
</span> </span>
<span v-else-if="vardata && vardata.type === 'int_by_datetime'">
<span v-if="variable.startsWith('entries_')" class="fa fa-sign-in"></span>
{{ vardata.label }}
<span v-if="node.rule[operator][0][variable][0].buildTime[0] === 'custom'">
{{ df(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else-if="node.rule[operator][0][variable][0].buildTime[0] === 'customtime'">
{{ tf(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else>
{{ this.$root.texts[node.rule[operator][0][variable][0].buildTime[0]] }}
</span>
<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
<span v-else-if="vardata && variable === 'now'"> <span v-else-if="vardata && variable === 'now'">
<span class="fa fa-clock-o"></span> {{ vardata.label }}<br> <span class="fa fa-clock-o"></span> {{ vardata.label }}<br>
<span v-if="varresult !== null"> <span v-if="varresult !== null">
@@ -44,7 +64,9 @@
</strong> </strong>
</span> </span>
<span v-else-if="vardata && operator === 'inList'"> <span v-else-if="vardata && operator === 'inList'">
<span class="fa fa-ticket"></span> {{ vardata.label }} <span class="fa fa-sign-in" v-if="variable === 'gate'"></span>
<span class="fa fa-ticket" v-else></span>
{{ vardata.label }}
<span v-if="varresult !== null"> <span v-if="varresult !== null">
({{varresult}}) ({{varresult}})
</span> </span>
@@ -138,6 +160,12 @@
variable () { variable () {
const op = this.operator; const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) { 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"]; return this.node.rule[op][0]["var"];
} else { } else {
return ""; return "";
@@ -149,6 +177,8 @@
varresult () { varresult () {
const op = this.operator; const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) { 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"]; return this.node.rule[op][0]["__result"];
} else { } else {
return ""; return "";
+138
View File
@@ -562,6 +562,44 @@ def test_rules_variation(item, position, clist):
perform_checkin(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 @pytest.mark.django_db
def test_rules_scan_number(position, clist): def test_rules_scan_number(position, clist):
# Ticket is valid three times # 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) 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 @pytest.mark.django_db
def test_rules_time_isafter_tolerance(event, position, clist): def test_rules_time_isafter_tolerance(event, position, clist):
# Ticket is valid starting 10 minutes before admission time # Ticket is valid starting 10 minutes before admission time