Web-based check-in interface (#1985)

This commit is contained in:
Raphael Michel
2021-03-30 09:34:11 +02:00
committed by GitHub
parent b06cded172
commit 92a50cb2d1
56 changed files with 3578 additions and 58 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.12 on 2021-03-29 08:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0180_auto_20210324_1309'),
]
operations = [
migrations.AddField(
model_name='team',
name='can_checkin_orders',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,4 +1,5 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
from django.utils.timezone import now
@@ -142,6 +143,54 @@ class CheckinList(LoggedModel):
def __str__(self):
return self.name
@classmethod
def validate_rules(cls, rules, seen_nonbool=False, depth=0):
# While we implement a full jsonlogic machine on Python-level, we also use the logic rules to generate
# SQL queries, which is not a full implementation of JSON logic right now, but makes some assumptions,
# e.g. it does not support something like (a AND b) == (c OR D)
# Every change to our supported JSON logic must be done
# * in pretix.base.services.checkin
# * in pretix.base.models.checkin
# * in checkinrules.js
# * in libpretixsync
top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
}
allowed_operators = top_level_operators | {
'buildTime', 'objectList', 'lookup', 'var',
}
allowed_vars = {
'product', 'variation', 'now', 'entries_number', 'entries_today', 'entries_days'
}
if not rules or not isinstance(rules, dict):
return
if len(rules) > 1:
raise ValidationError(f'Rules should not include dictionaries with more than one key, found: "{rules}".')
operator = list(rules.keys())[0]
if operator not in allowed_operators:
raise ValidationError(f'Logic operator "{operator}" is currently not allowed.')
if depth == 0 and operator not in top_level_operators:
raise ValidationError(f'Logic operator "{operator}" is currently not allowed on the first level.')
values = rules[operator]
if not isinstance(values, list) and not isinstance(values, tuple):
values = [values]
if operator == 'var':
if values[0] not in allowed_vars:
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
return
if operator in ('or', 'and') and seen_nonbool:
raise ValidationError(f'You cannot use OR/AND logic on a level below a comparison operator.')
for v in values:
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)
class Checkin(models.Model):
"""

View File

@@ -1088,17 +1088,23 @@ class Question(LoggedModel):
)
dependency_values = MultiStringField(default=[])
valid_number_min = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_number_max = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_date_min = models.DateField(null=True, blank=True,
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_date_max = models.DateField(null=True, blank=True,
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_datetime_min = models.DateTimeField(null=True, blank=True,
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_datetime_max = models.DateTimeField(null=True, blank=True,
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
objects = ScopedManager(organizer='event__organizer')

View File

@@ -174,6 +174,8 @@ class Team(LoggedModel):
:type can_view_orders: bool
:param can_change_orders: If ``True``, the members can change details of orders of the associated events.
:type can_change_orders: bool
:param can_checkin_orders: If ``True``, the members can perform check-in related actions.
:type can_checkin_orders: bool
:param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events.
:type can_view_vouchers: bool
:param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events.
@@ -220,6 +222,12 @@ class Team(LoggedModel):
default=False,
verbose_name=_("Can change orders")
)
can_checkin_orders = models.BooleanField(
default=False,
verbose_name=_("Can perform check-ins"),
help_text=_('This includes searching for attendees, which can be used to obtain personal information about '
'attendees. Users with "can change orders" can also perform check-ins.')
)
can_view_vouchers = models.BooleanField(
default=False,
verbose_name=_("Can view vouchers")

View File

@@ -191,7 +191,7 @@ class ParametrizedOrderNotificationType(NotificationType):
n.add_attribute(_('Net total'), money_filter(sum([p.net_price for p in positions] + [f.net_value for f in fees]), logentry.event.currency))
n.add_attribute(_('Order total'), money_filter(order.total, logentry.event.currency))
n.add_attribute(_('Pending amount'), money_filter(order.pending_sum, logentry.event.currency))
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
n.add_attribute(_('Order date'), date_format(order.datetime.astimezone(logentry.event.timezone), 'SHORT_DATETIME_FORMAT'))
n.add_attribute(_('Order status'), order.get_status_display())
n.add_attribute(_('Order positions'), str(order.positions.count()))

View File

@@ -1,9 +1,15 @@
from datetime import timedelta
from functools import partial, reduce
import dateutil
import dateutil.parser
from django.core.files import File
from django.db import transaction
from django.db.models.functions import TruncDate
from django.db.models import (
BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q,
Subquery, Value,
)
from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import now, override
@@ -15,9 +21,18 @@ from pretix.base.models import (
)
from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_query import (
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
tolerance,
)
def get_logic_environment(ev):
# Every change to our supported JSON logic must be done
# * in pretix.base.services.checkin
# * in pretix.base.models.checkin
# * in checkinrules.js
# * in libpretixsync
def build_time(t=None, value=None):
if t == "custom":
return dateutil.parser.parse(value)
@@ -82,10 +97,181 @@ class LazyRuleVars:
tz = self._clist.event.timezone
with override(tz):
return self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).annotate(
day=TruncDate('datetime')
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
class SQLLogic:
"""
This is a simplified implementation of JSON logic that creates a Q-object to be used in a QuerySet.
It does not implement all operations supported by JSON logic and makes a few simplifying assumptions,
but all that can be created through our graphical editor. There's also CheckinList.validate_rules()
which tries to validate the same preconditions for rules set throught he API (probably not perfect).
Assumptions:
* Only a limited set of operators is used
* The top level operator is always a boolean operation (and, or) or a comparison operation (==, !=, …)
* Expression operators (var, lookup, buildTime) do not require further recursion
* Comparison operators (==, !=, …) never contain boolean operators (and, or) further down in the stack
"""
def __init__(self, list):
self.list = list
self.bool_ops = {
"and": lambda *args: reduce(lambda total, arg: total & arg, args),
"or": lambda *args: reduce(lambda total, arg: total | arg, args),
}
self.comparison_ops = {
"==": partial(self.comparison_to_q, operator=Equal),
"!=": partial(self.comparison_to_q, operator=Equal, negate=True),
">": partial(self.comparison_to_q, operator=GreaterThan),
">=": partial(self.comparison_to_q, operator=GreaterEqualThan),
"<": partial(self.comparison_to_q, operator=LowerThan),
"<=": partial(self.comparison_to_q, operator=LowerEqualThan),
"inList": partial(self.comparison_to_q, operator=InList),
"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'}
def operation_to_expression(self, rule):
if not isinstance(rule, dict):
return rule
operator = list(rule.keys())[0]
values = rule[operator]
if not isinstance(values, list) and not isinstance(values, tuple):
values = [values]
if operator == 'buildTime':
if values[0] == "custom":
return Value(dateutil.parser.parse(values[1]))
elif values[0] == 'date_from':
return Coalesce(
F(f'subevent__date_from'),
F(f'order__event__date_from'),
)
elif values[0] == 'date_to':
return Coalesce(
F(f'subevent__date_to'),
F(f'subevent__date_from'),
F(f'order__event__date_to'),
F(f'order__event__date_from'),
)
elif values[0] == 'date_admission':
return Coalesce(
F(f'subevent__date_admission'),
F(f'subevent__date_from'),
F(f'order__event__date_admission'),
F(f'order__event__date_from'),
)
else:
raise ValueError(f'Unknown time type {values[0]}')
elif operator == 'objectList':
return [self.operation_to_expression(v) for v in values]
elif operator == 'lookup':
return int(values[1])
elif operator == 'var':
if values[0] == 'now':
return Value(now())
elif values[0] == 'product':
return F('item_id')
elif values[0] == 'variation':
return F('variation_id')
elif values[0] == 'entries_number':
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk
).values('position_id').order_by().annotate(
c=Count('*')
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif values[0] == 'entries_today':
midnight = now().astimezone(self.list.event.timezone).replace(hour=0, minute=0, second=0, microsecond=0)
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__gte=midnight,
).values('position_id').order_by().annotate(
c=Count('*')
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif values[0] == 'entries_days':
tz = self.list.event.timezone
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('position_id').order_by().annotate(
c=Count('day', distinct=True)
).values('c')
),
Value(0),
output_field=IntegerField()
)
else:
raise ValueError(f'Unknown operator {operator}')
def comparison_to_q(self, a, b, *args, operator, negate=False, modifier=None):
a = self.operation_to_expression(a)
b = self.operation_to_expression(b)
if modifier:
b = modifier(b, *args)
q = Q(
ExpressionWrapper(
operator(
a,
b,
),
output_field=BooleanField()
)
)
return ~q if negate else q
def apply(self, tests):
"""
Convert JSON logic to queryset info, returns an Q object and fills self.annotations
"""
if not tests:
return Q()
if isinstance(tests, bool):
# not really a legal configuration but used in the test suite
return Value(tests, output_field=BooleanField())
operator = list(tests.keys())[0]
values = tests[operator]
# Easy syntax for unary operators, like {"var": "x"} instead of strict
# {"var": ["x"]}
if not isinstance(values, list) and not isinstance(values, tuple):
values = [values]
if operator in self.bool_ops:
return self.bool_ops[operator](*[self.apply(v) for v in values])
elif operator in self.comparison_ops:
return self.comparison_ops[operator](*values)
else:
raise ValueError(f'Invalid operator {operator} on first level')
class CheckInError(Exception):
def __init__(self, msg, code):
self.msg = msg
@@ -207,7 +393,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'product'
)
elif op.order.status != Order.STATUS_PAID and not force and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
):
raise CheckInError(
_('This order is not marked as paid.'),