Check-in: Add rule for number of days with entries since (#3808)

This commit is contained in:
Raphael Michel
2024-01-12 17:09:51 +01:00
committed by GitHub
parent bae1512235
commit 0220965ca9
6 changed files with 175 additions and 6 deletions

View File

@@ -280,7 +280,8 @@ class CheckinList(LoggedModel):
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
}
allowed_operators = top_level_operators | {
'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'
'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before', 'entries_days_since',
'entries_days_before',
}
allowed_vars = {
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
@@ -309,7 +310,7 @@ class CheckinList(LoggedModel):
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
return rules
if operator in ('entries_since', 'entries_before'):
if operator in ('entries_since', 'entries_before', 'entries_days_since', 'entries_days_before'):
if len(values) != 1 or "buildTime" not in values[0]:
raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.')

View File

@@ -202,7 +202,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
'var': values[0]["var"],
'rhs': values[1:],
}
elif "entries_since" in values[0] or "entries_before" in values[0]:
elif any(t in values[0] for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")):
_var_explanations[new_var_name] = {
'operator': operator,
'var': values[0],
@@ -280,11 +280,13 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
var_weights[vname] = (500, 0)
var_texts[vname] = _('Wrong entrance gate')
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday') \
or (isinstance(var, dict) and ("entries_since" in var or "entries_before" in var)):
or (isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before"))):
w = {
'minutes_since_first_entry': 80,
'minutes_since_last_entry': 90,
'entries_days': 100,
'entries_days_since': 105,
'entries_days_before': 105,
'entries_since': 110,
'entries_before': 110,
'entries_number': 120,
@@ -307,10 +309,12 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
'entries_today': _('number of entries today'),
'entries_since': _('number of entries since {datetime}'),
'entries_before': _('number of entries before {datetime}'),
'entries_days_since': _('number of days with an entry since {datetime}'),
'entries_days_before': _('number of days with an entry before {datetime}'),
'now_isoweekday': _('week day'),
}
if isinstance(var, dict) and ("entries_since" in var or "entries_before" in var):
if isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")):
varname = list(var.keys())[0]
cutoff = _build_time(*var[varname][0]['buildTime'], ev=ev, now_dt=now_dt).astimezone(ev.timezone)
if abs(now_dt - cutoff) < timedelta(hours=12):
@@ -410,6 +414,8 @@ def _get_logic_environment(ev, rule_data, now_dt):
logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol))
logic.add_operation('entries_since', lambda t1: rule_data.entries_since(t1))
logic.add_operation('entries_before', lambda t1: rule_data.entries_before(t1))
logic.add_operation('entries_days_since', lambda t1: rule_data.entries_days_since(t1))
logic.add_operation('entries_days_before', lambda t1: rule_data.entries_days_before(t1))
return logic
@@ -467,6 +473,32 @@ class LazyRuleVars:
self.__cache['entries_before', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__lt=cutoff).count()
return self.__cache['entries_before', cutoff]
def entries_days_since(self, cutoff):
tz = self._clist.event.timezone
with override(tz):
if ('entries_days_since', cutoff) not in self.__cache:
self.__cache['entries_days_since', cutoff] = self._position.checkins.filter(
type=Checkin.TYPE_ENTRY,
list=self._clist,
datetime__gte=cutoff
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
return self.__cache['entries_days_since', cutoff]
def entries_days_before(self, cutoff):
tz = self._clist.event.timezone
with override(tz):
if ('entries_days_before', cutoff) not in self.__cache:
self.__cache['entries_days_before', cutoff] = self._position.checkins.filter(
type=Checkin.TYPE_ENTRY,
list=self._clist,
datetime__lt=cutoff
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
return self.__cache['entries_days_before', cutoff]
@cached_property
def entries_days(self):
tz = self._clist.event.timezone
@@ -533,7 +565,8 @@ class SQLLogic:
"isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)),
"isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)),
}
self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'}
self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before',
'entries_days_since', 'entries_days_before'}
def operation_to_expression(self, rule):
if not isinstance(rule, dict):
@@ -611,6 +644,42 @@ class SQLLogic:
Value(0),
output_field=IntegerField()
)
elif operator == 'entries_days_since':
tz = self.list.event.timezone
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__gte=self.operation_to_expression(values[0]),
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('position_id').order_by().annotate(
c=Count('day', distinct=True)
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif operator == 'entries_days_before':
tz = self.list.event.timezone
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__lt=self.operation_to_expression(values[0]),
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('position_id').order_by().annotate(
c=Count('day', distinct=True)
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif operator == 'var':
if values[0] == 'now':
return Value(now().astimezone(timezone.utc))

View File

@@ -129,6 +129,14 @@ $(function () {
'label': gettext('Number of days with a previous entry'),
'type': 'int',
},
'entries_days_since': {
'label': gettext('Number of days with a previous entry since'),
'type': 'int_by_datetime',
},
'entries_days_before': {
'label': gettext('Number of days with a previous entry before'),
'type': 'int_by_datetime',
},
'minutes_since_last_entry': {
'label': gettext('Minutes since last entry (-1 on first entry)'),
'type': 'int',

View File

@@ -93,6 +93,12 @@
if (this.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.rule[op][0]["var"];
} else {
return null;

View File

@@ -166,6 +166,12 @@
if (this.node.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.node.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.node.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.node.rule[op][0]["var"];
} else {
return "";

View File

@@ -846,6 +846,85 @@ def test_rules_entries_before(event, position, clist):
assert 'Minimum number of entries before 23:00 exceeded' in str(excinfo.value)
@pytest.mark.django_db
def test_rules_entries_days_since(event, position, clist):
# Ticket is valid once before X and on one day after X
event.settings.timezone = 'Europe/Berlin'
clist.allow_multiple_entries = True
clist.rules = {
"or": [
{"<=": [{"var": "entries_number"}, 0]},
{"and": [
{"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}, 0]},
{"or": [
{">": [{"var": "entries_today"}, 0]},
{"<=": [{"entries_days_since": [{"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}]}, 0]},
]}
]},
],
}
clist.save()
with freeze_time("2020-01-01 22:00:00+01:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
assert not OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {})
assert excinfo.value.code == 'rules'
assert 'Maximum number of entries exceeded' in str(excinfo.value)
with freeze_time("2020-01-02 23:10:00+01:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
perform_checkin(position, clist, {})
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
with freeze_time("2020-01-03 23:10:00+01:00"):
assert not OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {})
assert excinfo.value.code == 'rules'
assert 'Maximum number of days with an entry since 2020-01-01 23:00 exceeded' in str(excinfo.value)
@pytest.mark.django_db
def test_rules_entries_days_before(event, position, clist):
# Ticket is valid after 23:00 only if people already showed up on two days before
event.settings.timezone = 'Europe/Berlin'
clist.allow_multiple_entries = True
clist.rules = {
"or": [
{"isBefore": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}, 0]},
{"and": [
{"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}, 0]},
{">=": [{"entries_days_before": [{"buildTime": ["custom", "2020-01-01T23:00:00.000+01:00"]}]}, 2]},
]},
],
}
clist.save()
with freeze_time("2019-12-30 22:00:00+01:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
with freeze_time("2020-01-02 23:10:00+01:00"):
assert not OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {})
assert excinfo.value.code == 'rules'
assert 'Minimum number of days with an entry before 2020-01-01 23:00 exceeded.' in str(excinfo.value)
with freeze_time("2019-12-31 22:00:00+01:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
with freeze_time("2020-01-02 23:10:00+01:00"):
assert OrderPosition.objects.filter(SQLLogic(clist).apply(clist.rules), pk=position.pk).exists()
perform_checkin(position, clist, {})
@pytest.mark.django_db
def test_rules_time_isafter_tolerance(event, position, clist):
# Ticket is valid starting 10 minutes before admission time