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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('.')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
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.'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
src/pretix/helpers/compressor.py
Normal file
42
src/pretix/helpers/compressor.py
Normal 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)
|
||||
59
src/pretix/helpers/jsonlogic_query.py
Normal file
59
src/pretix/helpers/jsonlogic_query.py
Normal 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
|
||||
22
src/pretix/plugins/webcheckin/__init__.py
Normal file
22
src/pretix/plugins/webcheckin/__init__.py
Normal 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'
|
||||
27
src/pretix/plugins/webcheckin/signals.py
Normal file
27
src/pretix/plugins/webcheckin/signals.py
Normal 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'),
|
||||
}]
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
8
src/pretix/plugins/webcheckin/urls.py
Normal file
8
src/pretix/plugins/webcheckin/urls.py
Normal 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'),
|
||||
]
|
||||
20
src/pretix/plugins/webcheckin/views.py
Normal file
20
src/pretix/plugins/webcheckin/views.py
Normal 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
|
||||
@@ -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
1673
src/pretix/static/npm_dir/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
src/pretix/static/npm_dir/package.json
Normal file
16
src/pretix/static/npm_dir/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
src/pretix/static/npm_dir/rollup.config.js
Normal file
23
src/pretix/static/npm_dir/rollup.config.js
Normal 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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
25
src/pretix/static/pretixbase/js/i18nstring.js
Normal file
25
src/pretix/static/pretixbase/js/i18nstring.js
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user