mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Web-based check-in interface (#1985)
This commit is contained in:
18
src/pretix/base/migrations/0181_team_can_checkin_orders.py
Normal file
18
src/pretix/base/migrations/0181_team_can_checkin_orders.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
Reference in New Issue
Block a user