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

@@ -46,8 +46,12 @@ class EventPermission(BasePermission):
else:
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if required_permission and required_permission not in request.eventpermset:
return False
if isinstance(required_permission, (list, tuple)):
if not any(p in request.eventpermset for p in required_permission):
return False
else:
if required_permission and required_permission not in request.eventpermset:
return False
elif 'organizer' in request.resolver_match.kwargs:
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request):
@@ -57,8 +61,12 @@ class EventPermission(BasePermission):
else:
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if required_permission and required_permission not in request.orgapermset:
return False
if isinstance(required_permission, (list, tuple)):
if not any(p in request.eventpermset for p in required_permission):
return False
else:
if required_permission and required_permission not in request.orgapermset:
return False
if isinstance(request.auth, OAuthAccessToken):
if not request.auth.allow_scopes(['write']) and request.method not in SAFE_METHODS:

View File

@@ -2,6 +2,7 @@ from django.utils.translation import gettext as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.models import CheckinList
@@ -20,6 +21,9 @@ class CheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True)
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')
if p[0] in self.fields:
@@ -50,4 +54,6 @@ class CheckinListSerializer(I18nAwareModelSerializer):
if channel not in get_all_sales_channels():
raise ValidationError(_('Unknown sales channel.'))
CheckinList.validate_rules(data.get('rules'))
return data

View File

@@ -13,7 +13,11 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer,
)
from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
@@ -349,8 +353,9 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields.pop('pdf_data')
request = self.context.get('request')
if not request or not self.context['request'].query_params.get('pdf_data', 'false') == 'true' or 'can_view_orders' not in request.eventpermset:
self.fields.pop('pdf_data', None)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
@@ -484,6 +489,18 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
'order__status')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True)
if 'item' in self.context['request'].query_params.getlist('expand'):
self.fields['item'] = ItemSerializer(read_only=True)
if 'variation' in self.context['request'].query_params.getlist('expand'):
self.fields['variation'] = InlineItemVariationSerializer(read_only=True)
class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2
@@ -584,7 +601,7 @@ class OrderSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields['positions'].child.fields.pop('pdf_data')
self.fields['positions'].child.fields.pop('pdf_data', None)
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')

View File

@@ -95,7 +95,7 @@ class TeamSerializer(serializers.ModelSerializer):
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers'
'can_change_vouchers', 'can_checkin_orders'
)
def validate(self, data):

View File

@@ -25,13 +25,14 @@ from pretix.base.models import (
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
)
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, perform_checkin,
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
)
from pretix.helpers.database import FixedOrderBy
with scopes_disabled():
class CheckinListFilter(FilterSet):
subevent_match = django_filters.NumberFilter(method='subevent_match_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta:
model = CheckinList
@@ -42,19 +43,35 @@ with scopes_disabled():
Q(subevent_id=value) | Q(subevent_id__isnull=True)
)
def ends_after_qs(self, queryset, name, value):
expr = (
Q(subevent__isnull=True) |
Q(
Q(Q(subevent__date_to__isnull=True) & Q(subevent__date_from__gte=value))
| Q(Q(subevent__date_to__isnull=False) & Q(subevent__date_to__gte=value))
)
)
return queryset.filter(expr)
class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
queryset = CheckinList.objects.none()
filter_backends = (DjangoFilterBackend,)
filterset_class = CheckinListFilter
permission = 'can_view_orders'
permission = ('can_view_orders', 'can_checkin_orders',)
write_permission = 'can_change_event_settings'
def get_queryset(self):
qs = self.request.event.checkin_lists.prefetch_related(
'limit_products',
)
if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values'
)
return qs
def perform_create(self, serializer):
@@ -155,15 +172,37 @@ class CheckinListViewSet(viewsets.ModelViewSet):
with scopes_disabled():
class CheckinOrderPositionFilter(OrderPositionFilter):
check_rules = django_filters.rest_framework.BooleanFilter(method='check_rules_qs')
# check_rules is currently undocumented on purpose, let's get a feel for the performance impact first
def __init__(self, *args, **kwargs):
self.checkinlist = kwargs.pop('checkinlist')
super().__init__(*args, **kwargs)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value)
def check_rules_qs(self, queryset, name, value):
if not self.checkinlist.rules:
return queryset
return queryset.filter(SQLLogic(self.checkinlist).apply(self.checkinlist.rules))
class ExtendedBackend(DjangoFilterBackend):
def get_filterset_kwargs(self, request, queryset, view):
kwargs = super().get_filterset_kwargs(request, queryset, view)
# merge filterset kwargs provided by view class
if hasattr(view, 'get_filterset_kwargs'):
kwargs.update(view.get_filterset_kwargs())
return kwargs
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filter_backends = (ExtendedBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid')
ordering_fields = (
'order__code', 'order__datetime', 'positionid', 'attendee_name',
@@ -187,8 +226,13 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
}
filterset_class = CheckinOrderPositionFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
permission = ('can_view_orders', 'can_checkin_orders')
write_permission = ('can_change_orders', 'can_checkin_orders')
def get_filterset_kwargs(self):
return {
'checkinlist': self.checkinlist,
}
@cached_property
def checkinlist(self):
@@ -209,7 +253,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
order__event=self.request.event,
).annotate(
last_checked_in=Subquery(cqs)
)
).prefetch_related('order__event', 'order__event__organizer')
if self.checkinlist.subevent:
qs = qs.filter(
subevent=self.checkinlist.subevent
@@ -255,6 +299,22 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
if not self.checkinlist.all_products and not ignore_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values'
)
if 'item' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values', 'item__variations').select_related('item__tax_rule')
if 'variation' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related('variation')
if 'pk' not in self.request.resolver_match.kwargs and 'can_view_orders' not in self.request.eventpermset \
and len(self.request.query_params.get('search', '')) < 3:
qs = qs.none()
return qs
@action(detail=False, methods=['POST'], url_name='redeem', url_path='(?P<pk>.*)/redeem')

View File

@@ -259,7 +259,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
qs = filter_qs_by_attr(qs, self.request)
return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings', 'meta_values'
)
def list(self, request, **kwargs):

View File

@@ -49,7 +49,9 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons', 'bundles').all()
return self.request.event.items.select_related('tax_rule').prefetch_related(
'variations', 'addons', 'bundles', 'meta_values'
).all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)

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.'),

View File

@@ -66,7 +66,9 @@ def _default_context(request):
if complain_testmode_orders is None:
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
ctx['complain_testmode_orders'] = complain_testmode_orders
ctx['complain_testmode_orders'] = complain_testmode_orders and request.user.has_event_permission(
request.organizer, request.event, 'can_view_orders', request=request
)
else:
ctx['complain_testmode_orders'] = False

View File

@@ -105,6 +105,11 @@ class CheckinListForm(forms.ModelForm):
'exit_all_at': NextTimeField,
}
def clean(self):
d = super().clean()
CheckinList.validate_rules(d.get('rules'))
return d
class SimpleCheckinListForm(forms.ModelForm):
def __init__(self, **kwargs):

View File

@@ -1134,6 +1134,13 @@ class TicketSettingsForm(SettingsForm):
class CommentForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.readonly = kwargs.pop('readonly')
super().__init__(*args, **kwargs)
if self.readonly:
self.fields['comment'].widget.attrs['readonly'] = 'readonly'
class Meta:
model = Event
fields = ['comment']

View File

@@ -149,7 +149,7 @@ class TeamForm(forms.ModelForm):
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders',
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
'can_view_vouchers', 'can_change_vouchers']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={

View File

@@ -514,5 +514,5 @@ def merge_in(nav, newnav):
if 'children' not in parents[0]:
parents[0]['children'] = []
parents[0]['children'].append(item)
else:
nav.append(item)
continue
nav.append(item)

View File

@@ -283,6 +283,7 @@
{% for nav in nav_items %}
<li>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.external %}target="_blank"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon %}
{% if "<svg" in nav.icon %}
@@ -301,6 +302,7 @@
{% for item in nav.children %}
<li>
<a href="{{ item.url }}"
{% if item.external %}target="_blank"{% endif %}
{% if item.active %}class="active"{% endif %}>
{{ item.label }}
</a>

View File

@@ -150,12 +150,14 @@
<div class="row">
{% bootstrap_field comment_form.comment layout="horizontal" show_help=True show_label=False horizontal_field_class="col-md-12" %}
</div>
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% if not comment_form.readonly %}
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% endif %}
</form>
</div>
</div>

View File

@@ -35,6 +35,7 @@
{% bootstrap_field form.can_change_items layout="control" %}
{% bootstrap_field form.can_view_orders layout="control" %}
{% bootstrap_field form.can_change_orders layout="control" %}
{% bootstrap_field form.can_checkin_orders layout="control" %}
{% bootstrap_field form.can_view_vouchers layout="control" %}
{% bootstrap_field form.can_change_vouchers layout="control" %}
</fieldset>

View File

@@ -22,8 +22,8 @@ from django.utils.translation import gettext_lazy as _, pgettext, ungettext
from pretix.base.decimal import round_decimal
from pretix.base.models import (
Item, ItemVariation, Order, OrderPosition, OrderRefund, RequiredAction,
SubEvent, Voucher, WaitingListEntry,
Item, ItemCategory, ItemVariation, Order, OrderPosition, OrderRefund,
Question, Quota, RequiredAction, SubEvent, Voucher, WaitingListEntry,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
@@ -313,19 +313,40 @@ def event_index(request, organizer, event):
except SubEvent.DoesNotExist:
pass
widgets = []
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'can_view_orders',
request=request)
can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders',
request=request)
can_change_event_settings = request.user.has_event_permission(request.organizer, request.event,
'can_change_event_settings', request=request)
can_view_vouchers = request.user.has_event_permission(request.organizer, request.event, 'can_view_vouchers',
request=request)
widgets = []
if can_view_orders:
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application',
'device').order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request):
if not can_view_orders:
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order))
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_vouchers', request=request):
if not can_view_vouchers:
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
if not can_change_event_settings:
allowed_types = [
ContentType.objects.get_for_model(Voucher),
ContentType.objects.get_for_model(Order)
]
if request.user.has_event_permission(request.organizer, request.event, 'can_change_items', request=request):
allowed_types += [
ContentType.objects.get_for_model(Item),
ContentType.objects.get_for_model(ItemCategory),
ContentType.objects.get_for_model(Quota),
ContentType.objects.get_for_model(Question),
]
qs = qs.filter(content_type__in=allowed_types)
a_qs = request.event.requiredaction_set.filter(done=False)
@@ -334,25 +355,25 @@ def event_index(request, organizer, event):
'logs': qs[:5],
'subevent': subevent,
'actions': a_qs[:5] if can_change_orders else [],
'comment_form': CommentForm(initial={'comment': request.event.comment})
'comment_form': CommentForm(initial={'comment': request.event.comment}, readonly=not can_change_event_settings),
}
ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter(
ctx['has_overpaid_orders'] = can_view_orders and Order.annotate_overpayments(request.event.orders).filter(
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
).exists()
ctx['has_pending_orders_with_full_payment'] = Order.annotate_overpayments(request.event.orders).filter(
ctx['has_pending_orders_with_full_payment'] = can_view_orders and Order.annotate_overpayments(request.event.orders).filter(
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) & Q(require_approval=False)
).exists()
ctx['has_pending_refunds'] = OrderRefund.objects.filter(
ctx['has_pending_refunds'] = can_view_orders and OrderRefund.objects.filter(
order__event=request.event,
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_EXTERNAL)
).exists()
ctx['has_pending_approvals'] = request.event.orders.filter(
ctx['has_pending_approvals'] = can_view_orders and request.event.orders.filter(
status=Order.STATUS_PENDING,
require_approval=True
).exists()
ctx['has_cancellation_requests'] = CancellationRequest.objects.filter(
ctx['has_cancellation_requests'] = can_view_orders and CancellationRequest.objects.filter(
order__event=request.event
).exists()

View File

@@ -55,7 +55,9 @@ from pretix.plugins.stripe.payment import StripeSettingsHolder
from pretix.presale.style import regenerate_css
from ...base.i18n import language
from ...base.models.items import ItemMetaProperty
from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota,
)
from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList
from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView
@@ -971,6 +973,21 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_vouchers',
request=self.request):
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
if not self.request.user.has_event_permission(self.request.organizer, self.request.event,
'can_change_event_settings', request=self.request):
allowed_types = [
ContentType.objects.get_for_model(Voucher),
ContentType.objects.get_for_model(Order)
]
if self.request.user.has_event_permission(self.request.organizer, self.request.event,
'can_change_items', request=self.request):
allowed_types += [
ContentType.objects.get_for_model(Item),
ContentType.objects.get_for_model(ItemCategory),
ContentType.objects.get_for_model(Quota),
ContentType.objects.get_for_model(Question),
]
qs = qs.filter(content_type__in=allowed_types)
if self.request.GET.get('user') == 'yes':
qs = qs.filter(user__isnull=False)

View File

@@ -0,0 +1,42 @@
import os
import re
import shlex
from compressor.exceptions import FilterError
from compressor.filters import CompilerFilter
from django.conf import settings
class VueCompiler(CompilerFilter):
# Based on work (c) Laura Klünder in https://github.com/codingcatgirl/django-vue-rollup
# Released under Apache License 2.0
def __init__(self, content, attrs, **kwargs):
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'static', 'npm_dir')
node_path = os.path.join(settings.STATIC_ROOT, 'node_prefix', 'node_modules')
self.rollup_bin = os.path.join(node_path, 'rollup', 'dist', 'bin', 'rollup')
rollup_config = os.path.join(config_dir, 'rollup.config.js')
if not os.path.exists(self.rollup_bin) and not settings.DEBUG:
raise FilterError("Rollup not installed or pretix not built properly, please run 'make npminstall' in source root.")
command = (
' '.join((
'NODE_PATH=' + shlex.quote(node_path),
shlex.quote(self.rollup_bin),
'-c',
shlex.quote(rollup_config))
) +
' --input {infile} -n {export_name} --file {outfile}'
)
super().__init__(content, command=command, **kwargs)
def input(self, **kwargs):
if self.filename is None:
raise FilterError('VueCompiler can only compile files, not inline code.')
if not os.path.exists(self.rollup_bin):
raise FilterError("Rollup not installed, please run 'make npminstall' in source root.")
self.options += (('export_name', re.sub(
r'^([a-z])|[^a-z0-9A-Z]+([a-zA-Z0-9])?',
lambda s: s.group(0)[-1].upper(),
os.path.basename(self.filename).split('.')[0]
)),)
return super().input(**kwargs)

View File

@@ -0,0 +1,59 @@
import logging
from datetime import timedelta
from django.db.models import Func, Value
logger = logging.getLogger(__name__)
class Equal(Func):
arg_joiner = ' = '
arity = 2
function = ''
class GreaterThan(Func):
arg_joiner = ' > '
arity = 2
function = ''
class GreaterEqualThan(Func):
arg_joiner = ' >= '
arity = 2
function = ''
class LowerEqualThan(Func):
arg_joiner = ' < '
arity = 2
function = ''
class LowerThan(Func):
arg_joiner = ' < '
arity = 2
function = ''
class InList(Func):
arity = 2
def as_sql(self, compiler, connection, function=None, template=None, arg_joiner=None, **extra_context):
connection.ops.check_expression_support(self)
# This ignores the special case for databases which limit the number of
# elements which can appear in an 'IN' clause, which hopefully is only Oracle.
lhs, lhs_params = compiler.compile(self.source_expressions[0])
if not isinstance(self.source_expressions[1], Value) and not isinstance(self.source_expressions[1].value, (list, tuple)):
raise TypeError(f'Dynamic right-hand-site currently not implemented, found {type(self.source_expressions[1])}')
rhs, rhs_params = ['%s' for _ in self.source_expressions[1].value], [d for d in self.source_expressions[1].value]
return '%s IN (%s)' % (lhs, ', '.join(rhs)), lhs_params + rhs_params
def tolerance(b, tol=None, sign=1):
if tol:
return b + timedelta(minutes=sign * float(tol))
return b

View File

@@ -0,0 +1,22 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class WebCheckinApp(AppConfig):
name = 'pretix.plugins.webcheckin'
verbose_name = _("Web-based check-in")
class PretixPluginMeta:
name = _("Web-based check-in")
author = _("the pretix team")
version = version
category = "FEATURE"
description = _("This plugin allows you to perform check-in actions in your browser.")
def ready(self):
from . import signals # NOQA
default_app_config = 'pretix.plugins.webcheckin.WebCheckinApp'

View File

@@ -0,0 +1,27 @@
from django.dispatch import receiver
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid='webcheckin_nav_event')
def navbar_entry(sender, request, **kwargs):
url = request.resolver_match
if not request.user.has_event_permission(request.organizer, request.event, ('can_change_orders', 'can_checkin_orders'), request=request):
return []
return [{
'label': mark_safe(_('Web Check-in') + ' <span class="label label-success">beta</span>'),
'url': reverse('plugins:webcheckin:index', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}),
'parent': reverse('control:event.orders.checkinlists', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'external': True,
'icon': 'check-square-o',
'active': url.namespace == 'plugins:webcheckin' and url.url_name.startswith('index'),
}]

View File

@@ -0,0 +1,517 @@
<template>
<div id="app">
<div class="container">
<h1>
{{ $root.event_name }}
</h1>
<checkinlist-select v-if="!checkinlist" @selected="selectList($event)"></checkinlist-select>
<input v-if="checkinlist" v-model="query" ref="input" :placeholder="$root.strings['input.placeholder']" @keyup="inputKeyup" class="form-control scan-input">
<div v-if="checkResult !== null" class="panel panel-primary check-result">
<div class="panel-heading">
<a class="pull-right" @click.prevent="clear" href="#" tabindex="-1">
<span class="fa fa-close"></span>
</a>
<h3 class="panel-title">
{{ $root.strings['check.headline'] }}
</h3>
</div>
<div v-if="checkLoading" class="panel-body text-center">
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</div>
<div v-else-if="checkError" class="panel-body text-center">
{{ checkError }}
</div>
<div :class="'check-result-status check-result-' + checkResultColor">
{{ checkResultText }}
</div>
<div class="panel-body" v-if="checkResult.position">
<div class="details">
<h4>{{ checkResult.position.order }}-{{ checkResult.position.positionid }} {{ checkResult.position.attendee_name }}</h4>
<span>{{ checkResultItemvar }}</span>
<span v-if="checkResult.position.seat"><br>{{ checkResult.position.seat.name }}</span>
</div>
</div>
<div class="attention" v-if="checkResult && checkResult.require_attention">
<span class="fa fa-warning"></span>
{{ $root.strings['check.attention'] }}
</div>
</div>
<div v-else-if="searchResults !== null" class="panel panel-primary search-results">
<div class="panel-heading">
<a class="pull-right" @click.prevent="clear" href="#" tabindex="-1">
<span class="fa fa-close"></span>
</a>
<h3 class="panel-title">
{{ $root.strings['results.headline'] }}
</h3>
</div>
<ul class="list-group">
<searchresult-item ref="result" v-if="searchResults" v-for="p in searchResults" :position="p" :key="p.id" @selected="selectResult($event)"></searchresult-item>
<li v-if="!searchResults.length && !searchLoading" class="list-group-item text-center">
{{ $root.strings['results.none'] }}
</li>
<li v-if="searchLoading" class="list-group-item text-center">
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</li>
<li v-else-if="searchError" class="list-group-item text-center">
{{ searchError }}
</li>
<a v-else-if="searchNextUrl" class="list-group-item text-center" href="#" @click.prevent="searchNext">
{{ $root.strings['pagination.next'] }}
</a>
</ul>
</div>
<div v-else-if="checkinlist">
<div class="panel panel-default">
<div class="panel-body meta">
<div class="row settings">
<div class="col-sm-6">
<div>
<span :class="'fa fa-sign-' + (type === 'exit' ? 'out' : 'in')"></span>
{{ $root.strings['scantype.' + type] }}<br>
<button @click="switchType" class="btn btn-default"><span class="fa fa-refresh"></span> {{ $root.strings['scantype.switch'] }}</button>
</div>
</div>
<div class="col-sm-6">
<div v-if="checkinlist">
{{ checkinlist.name }}<br>
{{ subevent }}<br v-if="subevent">
<button @click="switchList" type="button" class="btn btn-default">{{ $root.strings['checkinlist.switch'] }}</button>
</div>
</div>
</div>
<div v-if="status" class="row status">
<div class="col-sm-4">
<span class="statistic">{{ status.checkin_count }}</span>
{{ $root.strings['status.checkin'] }}
</div>
<div class="col-sm-4">
<span class="statistic">{{ status.position_count }}</span>
{{ $root.strings['status.position'] }}
</div>
<div class="col-sm-4">
<div class="pull-right">
<button @click="fetchStatus" class="btn btn-default"><span :class="'fa fa-refresh' + (statusLoading ? ' fa-spin': '')"></span></button>
</div>
<span class="statistic">{{ status.inside_count }}</span>
{{ $root.strings['status.inside'] }}
</div>
</div>
</div>
</div>
</div>
</div>
<div :class="'modal modal-unpaid fade' + (showUnpaidModal ? ' in' : '')" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content" v-if="checkResult && checkResult.position">
<div class="modal-header">
<button type="button" class="close" @click="showUnpaidModal = false">
<span class="fa fa-close"></span>
</button>
<h4 class="modal-title">
{{ $root.strings['modal.unpaid.head'] }}
</h4>
</div>
<div class="modal-body">
<p>
{{ $root.strings['modal.unpaid.text'] }}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, false, false)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showUnpaidModal = false">
{{ $root.strings['modal.cancel'] }}
</button>
</div>
</div>
</div>
</div>
<form :class="'modal modal-questions fade' + (showQuestionsModal ? ' in' : '')" tabindex="-1" role="dialog" ref="questionsModal">
<div class="modal-dialog" role="document">
<div class="modal-content" v-if="checkResult && checkResult.questions">
<div class="modal-header">
<button type="button" class="close" @click="showQuestionsModal = false">
<span class="fa fa-close"></span>
</button>
<h4 class="modal-title">
{{ $root.strings['modal.questions'] }}
</h4>
</div>
<div class="modal-body">
<div :class="q.type === 'M' ? '' : (q.type === 'B' ? 'checkbox' : 'form-group')" v-for="q in checkResult.questions">
<label :for="'q_' + q.id" v-if="q.type !== 'B'">
{{ q.question }}
{{ q.required ? ' *' : '' }}
</label>
<textarea v-if="q.type === 'T'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required"></textarea>
<input v-else-if="q.type === 'N'" type="number" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
<datefield v-else-if="q.type === 'D'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" :required="q.required"></datefield>
<timefield v-else-if="q.type === 'H'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" :required="q.required"></timefield>
<datetimefield v-else-if="q.type === 'W'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" :required="q.required"></datetimefield>
<select v-else-if="q.type === 'C'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
<option v-if="!q.required"></option>
<option v-for="op in q.options" :value="op.id.toString()">{{ op.answer }}</option>
</select>
<div v-else-if="q.type === 'F'"><em>file input not supported</em></div>
<div v-else-if="q.type === 'M'">
<div class="checkbox" v-for="op in q.options">
<label>
<input type="checkbox" :checked="answers[q.id.toString()] && answers[q.id.toString()].split(',').includes(op.id.toString)" @input="answerSetM(q.id.toString(), op.id.toString(), $event.target.checked)">
{{ op.answer }}
</label>
</div>
</div>
<label v-else-if="q.type === 'B'">
<input type="checkbox" :checked="answers[q.id.toString()] === 'true'" @input="answers[q.id.toString()] = $event.target.checked.toString()" :required="q.required">
{{ q.question }}
{{ q.required ? ' *' : '' }}
</label>
<select v-else-if="q.type === 'CC'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
<option v-if="!q.required"></option>
<option v-for="op in countries" :value="op.key">{{ op.value }}</option>
</select>
<input v-else v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, true)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showQuestionsModal = false">
{{ $root.strings['modal.cancel'] }}
</button>
</div>
</div>
</div>
</form>
</div>
</template>
<script>
export default {
components: {
CheckinlistSelect: CheckinlistSelect.default,
SearchresultItem: SearchresultItem.default,
Datetimefield: Datetimefield.default,
Timefield: Timefield.default,
Datefield: Datefield.default,
},
data() {
return {
type: 'entry',
query: '',
searchLoading: false,
searchResults: null,
searchNextUrl: null,
searchError: null,
status: null,
statusLoading: 0,
statusInterval: null,
checkLoading: false,
checkError: null,
checkResult: null,
checkinlist: null,
clearTimeout: null,
showUnpaidModal: false,
showQuestionsModal: false,
answers: {},
}
},
mounted() {
window.addEventListener('focus', this.globalKeydown)
document.addEventListener("visibilitychange", this.globalKeydown)
document.addEventListener('keydown', this.globalKeydown)
this.statusInterval = window.setInterval(this.fetchStatus, 120 * 1000)
},
destroyed() {
window.removeEventListener('focus', this.globalKeydown)
document.removeEventListener("visibilitychange", this.globalKeydown)
document.removeEventListener('keydown', this.globalKeydown)
window.clearInterval(this.statusInterval)
window.clearInterval(this.clearTimeout)
},
computed: {
countries() {
return JSON.parse(document.querySelector("#countries").innerHTML);
},
subevent() {
if (!this.checkinlist) return ''
if (!this.checkinlist.subevent) return ''
const name = i18nstring_localize(this.checkinlist.subevent.name)
const date = moment.utc(this.checkinlist.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
},
checkResultItemvar() {
if (!this.checkResult) return ''
if (this.checkResult.position.variation) {
return `${i18nstring_localize(this.checkResult.position.item.name)} ${i18nstring_localize(this.checkResult.position.variation.value)}`
}
return i18nstring_localize(this.checkResult.position.item.name)
},
checkResultText () {
if (!this.checkResult) return ''
if (this.checkResult.status === 'ok') {
return this.$root.strings['result.ok']
} else if (this.checkResult.status === 'incomplete') {
return this.$root.strings['result.questions']
} else {
return this.$root.strings['result.' + this.checkResult.reason]
}
},
checkResultColor () {
if (!this.checkResult) return ''
if (this.checkResult.status === 'ok') {
return "green";
} else if (this.checkResult.status === 'incomplete') {
return "purple";
} else {
if (this.checkResult.reason === 'already_redeemed') return "orange";
return "red";
}
},
},
methods: {
selectResult(res) {
this.check(res.id, false, false, false)
},
answerSetM(qid, opid, checked) {
let arr = this.answers[qid] ? this.answers[qid].split(',') : [];
if (checked && !arr.includes(opid)) {
arr.push(opid)
} else if (!checked) {
arr = arr.filter(o => opid !== o)
}
this.answers[qid] = arr.join(',')
},
clear() {
this.query = ''
this.searchLoading = false
this.searchResults = null
this.searchNextUrl = null
this.searchError = null
this.checkLoading = false
this.checkError = null
this.checkResult = null
this.showUnpaidModal = false
this.showQuestionsModal = false
this.answers = {}
},
check(id, ignoreUnpaid, keepAnswers, fallbackToSearch) {
if (!keepAnswers) {
this.answers = {}
} else if (this.showQuestionsModal) {
if (!this.$refs.questionsModal.reportValidity()) {
return
}
}
this.showUnpaidModal = false
this.showQuestionsModal = false
this.checkLoading = true
this.checkError = null
this.checkResult = {}
window.clearInterval(this.clearTimeout)
fetch(this.$root.api.lists + this.checkinlist.id + '/positions/' + encodeURIComponent(id) + '/redeem/?expand=item&expand=variation', {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector("input[name=csrfmiddlewaretoken]").value,
'Content-Type': 'application/json'
},
body: JSON.stringify({
questions_supported: true,
canceled_supported: true,
ignore_unpaid: ignoreUnpaid || false,
type: this.type,
answers: this.answers,
})
})
.then(response => {
if (response.status === 404) {
return {
status: 'error',
reason: 'invalid',
}
}
if (!response.ok && response.status != 400) {
throw new Error("HTTP status " + response.status);
}
return response.json()
})
.then(data => {
this.checkLoading = false
this.checkResult = data
if (this.checkinlist.include_pending && data.status === 'error' && data.reason === 'unpaid') {
this.showUnpaidModal = true
this.$nextTick(() => {
document.querySelector(".modal-unpaid .btn-primary").focus()
})
} else if (data.status === 'incomplete') {
this.showQuestionsModal = true
for (const q of this.checkResult.questions) {
if (!this.answers[q.id.toString()]) {
this.answers[q.id.toString()] = ""
}
q.question = i18nstring_localize(q.question)
for (const o of q.options) {
o.answer = i18nstring_localize(o.answer)
}
}
this.$nextTick(() => {
document.querySelector(".modal-questions input, .modal-questions select, .modal-questions textarea").focus()
})
} else if (data.status === 'error' && data.reason === 'invalid' && fallbackToSearch) {
this.startSearch(false)
} else {
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
this.fetchStatus()
}
})
.catch(reason => {
this.checkLoading = false
this.checkResult = {}
this.checkError = reason.toString()
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
},
globalKeydown(e) {
if (document.activeElement.classList.contains('searchresult') && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
if (e.key === 'ArrowDown') {
document.activeElement.nextElementSibling.focus()
e.preventDefault()
return true
}
if (e.key === 'ArrowUp') {
document.activeElement.previousElementSibling.focus()
e.preventDefault()
return true
}
}
if (document.activeElement.nodeName.toLowerCase() !== 'input' && document.activeElement.nodeName.toLowerCase() !== 'textarea') {
if (e.key && e.key.match(/^[a-z0-9A-Z+/=<>#]$/)) {
this.query = ''
this.refocus()
}
}
},
refocus() {
this.$nextTick(() => {
this.$refs.input.focus()
})
},
inputKeyup(e) {
if (e.key === "Enter") {
this.startSearch(true)
} else if (this.query === '') {
this.clear()
}
},
startSearch(fallbackToScan) {
if (this.query.length >= 32 && fallbackToScan) {
// likely a secret, not a search result
this.check(this.query, false, false, true)
return
}
this.checkResult = null
this.searchLoading = true
this.searchError = null
this.searchResults = []
this.answers = {}
window.clearInterval(this.clearTimeout)
fetch(this.$root.api.lists + this.checkinlist.id + '/positions/?ignore_status=true&expand=subevent&expand=item&expand=variation&check_rules=true&search=' + encodeURIComponent(this.query))
.then(response => response.json())
.then(data => {
this.searchLoading = false
if (data.results) {
this.searchResults = data.results
this.searchNextUrl = data.next
if (data.results.length) {
if (data.results[0].secret === this.query) {
this.$nextTick(() => {
this.$refs.result[0].$refs.a.click()
})
} else {
this.$nextTick(() => {
this.$refs.result[0].$refs.a.focus()
})
}
} else {
this.$nextTick(() => {
this.$refs.input.blur()
})
}
} else {
this.searchError = data
}
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
.catch(reason => {
this.searchLoading = false
this.searchResults = []
this.searchError = reason
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
},
searchNext() {
this.searchLoading = true
this.searchError = null
window.clearInterval(this.clearTimeout)
fetch(this.searchNextUrl)
.then(response => response.json())
.then(data => {
this.searchLoading = false
if (data.results) {
this.searchResults.push(...data.results)
this.searchNextUrl = data.next
} else {
this.searchError = data
}
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
.catch(reason => {
this.searchLoading = false
this.searchError = reason
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
},
switchType() {
this.type = this.type === 'exit' ? 'entry' : 'exit'
this.refocus()
},
switchList() {
location.hash = ''
this.checkinlist = null
},
fetchStatus() {
this.statusLoading++
fetch(this.$root.api.lists + this.checkinlist.id + '/status/')
.then(response => response.json())
.then(data => {
this.statusLoading--
this.status = data
})
.catch(reason => {
this.statusLoading--
})
},
selectList(list) {
this.checkinlist = list
location.hash = '#' + list.id
this.refocus()
this.fetchStatus()
}
}
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<a class="list-group-item" href="#" @click.prevent="$emit('selected', list)">
<div class="row">
<div class="col-md-6">
{{ list.name }}
</div>
<div class="col-md-6 text-muted">
{{ subevent }}
</div>
</div>
</a>
</template>
<script>
export default {
components: {},
props: {
list: Object
},
computed: {
subevent () {
if (!this.list.subevent) return '';
const name = i18nstring_localize(this.list.subevent.name)
const date = moment.utc(this.list.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
}
},
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="panel panel-primary checkinlist-select">
<div class="panel-heading">
<h3 class="panel-title">
{{ $root.strings['checkinlist.select'] }}
</h3>
</div>
<ul class="list-group">
<checkinlist-item v-if="lists" v-for="l in lists" :list="l" :key="l.id" @selected="$emit('selected', l)"></checkinlist-item>
<li v-if="loading" class="list-group-item text-center">
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</li>
<li v-else-if="error" class="list-group-item text-center">
{{ error }}
</li>
<a v-else-if="next_url" class="list-group-item text-center" href="#" @click.prevent="loadNext">
{{ $root.strings['pagination.next'] }}
</a>
</ul>
</div>
</template>
<script>
export default {
components: {
CheckinlistItem: CheckinlistItem.default,
},
data() {
return {
loading: false,
error: null,
lists: null,
next_url: null,
}
},
// TODO: pagination
mounted() {
this.load()
},
methods: {
load() {
this.loading = true
const cutoff = moment().subtract(8, 'hours').toISOString()
if (location.hash) {
fetch(this.$root.api.lists + location.hash.substr(1) + '/' + '?expand=subevent')
.then(response => response.json())
.then(data => {
this.loading = false
if (data.id) {
this.$emit('selected', data)
} else {
location.hash = ''
this.load()
}
})
.catch(reason => {
location.hash = ''
this.load()
})
return
}
fetch(this.$root.api.lists + '?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=' + cutoff)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists = data.results
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
loadNext() {
this.loading = true
fetch(this.next_url)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists.push(...data.results)
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
},
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<input class="form-control" :required="required">
</template>
<script>
export default {
props: ["required", "value"],
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().format("YYYY-MM-DD"));
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value));
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-dateformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<input class="form-control" :required="required">
</template>
<script>
export default {
props: ["required", "value"],
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().toISOString());
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value));
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-datetimeformat"),
locale: $("body").attr("data-datetimelocale"),
timeZone: $("body").attr("data-timezone"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<a class="list-group-item searchresult" href="#" @click.prevent="$emit('selected', position)" ref="a">
<div class="details">
<h4>{{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}</h4>
<span>{{ itemvar }}<br></span>
<span v-if="subevent">{{ subevent }}<br></span>
<div class="secret">{{ position.secret }}</div>
</div>
<div :class="`status status-${status}`">
<span v-if="position.require_attention"><span class="fa fa-warning"></span><br></span>
{{ $root.strings[`status.${status}`] }}
</div>
</a>
</template>
<script>
export default {
components: {},
props: {
position: Object
},
computed: {
status() {
if (this.position.checkins.length) return 'redeemed';
return this.position.order__status
},
itemvar() {
if (this.position.variation) {
return `${i18nstring_localize(this.position.item.name)} ${i18nstring_localize(this.position.variation.value)}`
}
return i18nstring_localize(this.position.item.name)
},
subevent() {
if (!this.position.subevent) return ''
const name = i18nstring_localize(this.position.subevent.name)
const date = moment.utc(this.position.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
},
},
}
// secret
// status
// order code
// name
// seat
// require attention
</script>

View File

@@ -0,0 +1,54 @@
<template>
<input class="form-control" :required="required">
</template>
<script>
export default {
props: ["required", "value"],
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().format("HH:mm:ss"));
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value));
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-timeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script>

View File

@@ -0,0 +1,71 @@
/*global gettext, Vue, App*/
function gettext(msgid) {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid);
}
return msgid;
}
function ngettext(singular, plural, count) {
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
return django.ngettext(singular, plural, count);
}
return plural;
}
moment.locale(document.body.attributes['data-datetimelocale'].value)
window.vapp = new Vue({
components: {
App: App.default
},
render: function (h) {
return h('App')
},
data: {
api: {
lists: document.querySelector('#app').attributes['data-api-lists'].value,
},
strings: {
'checkinlist.select': gettext('Select a check-in list'),
'checkinlist.none': gettext('No active check-in lists found.'),
'checkinlist.switch': gettext('Switch check-in list'),
'results.headline': gettext('Search results'),
'results.none': gettext('No tickets found'),
'check.headline': gettext('Check-in result'),
'check.attention': gettext('This ticket requires special attention'),
'scantype.switch': gettext('Switch direction'),
'scantype.entry': gettext('Entry'),
'scantype.exit': gettext('Exit'),
'input.placeholder': gettext('Scan a ticket or search and press return…'),
'pagination.next': gettext('Load more'),
'status.p': gettext('Valid'),
'status.n': gettext('Unpaid'),
'status.c': gettext('Canceled'),
'status.e': gettext('Canceled'),
'status.redeemed': gettext('Redeemed'),
'modal.cancel': gettext('Cancel'),
'modal.continue': gettext('Continue'),
'modal.unpaid.head': gettext('Ticket not paid'),
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
'modal.questions': gettext('Additional information required'),
'result.ok': gettext('Valid ticket'),
'result.exit': gettext('Exit recorded'),
'result.already_redeemed': gettext('Ticket already used'),
'result.questions': gettext('Information required'),
'result.invalid': gettext('Invalid ticket'),
'result.product': gettext('Invalid product'),
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.canceled': gettext('Order canceled'),
'status.checkin': gettext('Checked-in Tickets'),
'status.position': gettext('Valid Tickets'),
'status.inside': gettext('Currently inside'),
},
event_name: document.querySelector('#app').attributes['data-event-name'].value,
timezone: document.body.attributes['data-timezone'].value,
datetime_format: document.body.attributes['data-datetimeformat'].value,
},
el: '#app'
})

View File

@@ -0,0 +1,153 @@
@import "pretixbase/scss/_variables.scss";
@import "bootstrap/scss/_bootstrap.scss";
@import "pretixbase/scss/_theme.scss";
@import "fontawesome/scss/font-awesome.scss";
@import "datetimepicker/_bootstrap-datetimepicker.scss";
body {
background: #FBF7FC;
}
.container {
padding-top: 20px;
padding-bottom: 20px;
}
.loading-icon {
color: $brand-primary;
-webkit-animation: fa-spin 8s infinite linear;
animation: fa-spin 8s infinite linear;
}
.meta {
padding: 20px;
text-align: center;
.settings {
font-size: 32px;
}
color: $text-muted;
.fa-sign-out {
color: $brand-warning;
}
.status {
padding-top: 20px;
color: $text-muted;
.statistic {
display: block;
font-size: 30px;
}
}
}
.scan-input {
margin-bottom: 20px;
}
a.searchresult {
display: flex;
flex-direction: row;
min-height: 96px;
padding: 0;
color: $text-color;
&:focus {
background: $gray-lightest;
}
h4 {
margin: 0 0 5px;
}
.details {
flex: auto 1 1;
padding: 10px;
}
.status {
flex: 128px 0 0;
font-weight: bold;
color: white;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
font-size: 140%;
&.status-p {
background: $brand-success;
}
&.status-c, &.status-e, &.status-n {
background: $brand-danger;
}
&.status-redeemed {
background: $brand-warning;
}
}
.secret {
word-break: break-word;
color: $text-muted;
}
}
.check-result-status {
height: 30vh;
max-height: 200px;
font-size: 35px;
text-transform: uppercase;
font-weight: bold;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
color: white;
&.check-result-red {
background: $brand-danger;
}
&.check-result-green {
background: $brand-success;
}
&.check-result-orange {
background: $brand-warning;
}
&.check-result-purple {
background: $brand-primary;
}
}
.attention {
padding: 10px;
text-align: center;
animation: blinking 1s infinite;
font-weight: bold;
font-size: 16px;
}
.modal {
background: rgba(0, 0, 0, 0.7);
}
.modal.fade.in {
display: block;
overflow: auto;
}
@-webkit-keyframes blinking {
0%, 49% {
background-color: $brand-primary;
color: white;
}
50%, 100% {
background-color: $brand-warning;
color: $brand-primary;
}
}
.panel-primary .panel-heading a {
color: white;
}
.modal-footer .btn-primary.pull-right {
margin-left: 10px;
}

View File

@@ -0,0 +1,51 @@
{% load compress %}
{% load static %}
{% load i18n %}
{% load hijack_tags %}
{% load statici18n %}
{% load eventurl %}
{% load escapejson %}
<!DOCTYPE html>
<html>
<head>
<title>{{ request.event.name }} :: {% trans "Check-in" %} :: {{ settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixplugins/webcheckin/scss/main.scss" %}"/>
{% endcompress %}
{% if DEBUG %}
<script type="text/javascript" src="{% url 'javascript-catalog' lang=request.LANGUAGE_CODE %}" async></script>
{% else %}
<script src="{% statici18n request.LANGUAGE_CODE %}"></script>
{% endif %}
{{ html_head|safe }}
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
</head>
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}"
data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}">
<div
data-api-lists="{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}"
data-event-name="{{ request.event.name }}"
id="app"></div>
{# TODO: use vue.min.js #}
{% compress js %}
<script type="text/javascript" src="{% static "pretixbase/js/i18nstring.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-timezone-with-data-1970-2030.js" %}"></script>
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-select.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/searchresult-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/app.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixplugins/webcheckin/main.js" %}"></script>
{% endcompress %}
<script type="application/json" id="countries">{{ countries|escapejson_dumps }}</script>
{% csrf_token %}
</body>
</html>

View File

@@ -0,0 +1,8 @@
from django.conf.urls import url
from .views import IndexView
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/webcheckin/$',
IndexView.as_view(), name='index'),
]

View File

@@ -0,0 +1,20 @@
from django.views.generic import TemplateView
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.countries import CachedCountries
class IndexView(EventPermissionRequiredMixin, TemplateView):
permission = ('can_change_orders', 'can_checkin_orders')
template_name = 'pretixplugins/webcheckin/index.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['countries'] = [
{
'key': key,
'value': name
}
for key, name in CachedCountries()
]
return ctx

View File

@@ -297,6 +297,7 @@ INSTALLED_APPS = [
'pretix.plugins.badges',
'pretix.plugins.manualpayment',
'pretix.plugins.returnurl',
'pretix.plugins.webcheckin',
'django_markup',
'django_otp',
'django_otp.plugins.otp_totp',
@@ -563,6 +564,7 @@ STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesSto
COMPRESS_PRECOMPILERS = (
('text/x-scss', 'django_libsass.SassCompiler'),
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
)
COMPRESS_ENABLED = COMPRESS_OFFLINE = not debug_fallback

1673
src/pretix/static/npm_dir/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"name": "pretix",
"version": "0.0.0",
"private": true,
"scripts": {},
"dependencies": {
"@babel/core": "^7.12.16",
"@babel/preset-env": "^7.12.16",
"@rollup/plugin-babel": "^5.3.0",
"@rollup/plugin-node-resolve": "^11.2.0",
"vue": "^2.6.10",
"rollup": "^1.17.0",
"rollup-plugin-vue": "^5.0.1",
"vue-template-compiler": "^2.6.10"
}
}

View File

@@ -0,0 +1,23 @@
import vue from 'rollup-plugin-vue'
import { getBabelOutputPlugin } from '@rollup/plugin-babel'
export default {
output: {
format: 'iife',
exports: 'named',
},
plugins: [
getBabelOutputPlugin({
presets: ['@babel/preset-env'],
// Running babel on iife output is apparently discouraged since it can lead to global
// variable leaks. Since we didn't get it to work on inputs, let's take that risk.
// (In our tests, it did not leak anything.)
allowAllFormats: true
}),
vue({
css: true,
compileTemplate: true,
needMap: false,
}),
],
};

View File

@@ -0,0 +1,25 @@
function i18nstring_localize(o) {
var locale = document.body.attributes['data-pretixlocale'].value
var short_locale = locale.split('-')[0]
if (o[locale])
return o[locale]
if (o[short_locale])
return o[short_locale]
for (k of Object.keys(o)) {
if (k.split('-')[0] === short_locale && o[k]) {
return o[k]
}
}
if (o['en'])
return o['en']
for (k of Object.keys(o)) {
if (o[k]) {
return o[k]
}
}
}

View File

@@ -1,5 +1,10 @@
$(document).ready(function () {
var TYPEOPS = {
// 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
'product': {
'inList': {
'label': gettext('is one of'),