mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Store all check-in attempts, not only successful ones (#2074)
This commit is contained in:
@@ -59,6 +59,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlist-failed_checkins'),
|
||||
('GET', 'api-v1:checkinlistpos-list'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
@@ -89,6 +90,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlist-failed_checkins'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
|
||||
@@ -49,7 +49,9 @@ class EventPermission(BasePermission):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
|
||||
if hasattr(view, '_get_permission_name'):
|
||||
required_permission = getattr(view, '_get_permission_name')(request)
|
||||
elif request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
|
||||
required_permission = getattr(view, 'write_permission')
|
||||
elif hasattr(view, 'permission'):
|
||||
required_permission = getattr(view, 'permission')
|
||||
|
||||
@@ -250,7 +250,30 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'type')
|
||||
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'type')
|
||||
|
||||
|
||||
class FailedCheckinSerializer(I18nAwareModelSerializer):
|
||||
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
|
||||
raw_barcode = serializers.CharField(required=True, allow_null=False)
|
||||
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none(), required=False, allow_null=True)
|
||||
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
raw_subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
|
||||
'raw_subevent', 'datetime', 'type', 'position')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
event = self.context['event']
|
||||
self.fields['raw_item'].queryset = event.items.all()
|
||||
self.fields['raw_variation'].queryset = ItemVariation.objects.filter(item__event=event)
|
||||
self.fields['position'].queryset = OrderPosition.all.filter(order__event=event)
|
||||
if event.has_subevents:
|
||||
self.fields['raw_subevent'].queryset = event.subevents.all()
|
||||
|
||||
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#
|
||||
import django_filters
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
|
||||
)
|
||||
@@ -34,16 +35,20 @@ from django_scopes import scopes_disabled
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import DateTimeField
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.checkin import CheckinListSerializer
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import CheckinListOrderPositionSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
|
||||
)
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
@@ -79,8 +84,14 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
queryset = CheckinList.objects.none()
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = CheckinListFilter
|
||||
permission = ('can_view_orders', 'can_checkin_orders',)
|
||||
write_permission = 'can_change_event_settings'
|
||||
|
||||
def _get_permission_name(self, request):
|
||||
if request.path.endswith('/failed_checkins/'):
|
||||
return 'can_checkin_orders', 'can_change_orders'
|
||||
elif request.method in SAFE_METHODS:
|
||||
return 'can_view_orders', 'can_checkin_orders',
|
||||
else:
|
||||
return 'can_change_event_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.checkin_lists.prefetch_related(
|
||||
@@ -125,6 +136,49 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@action(detail=True, methods=['POST'], url_name='failed_checkins')
|
||||
@transaction.atomic()
|
||||
def failed_checkins(self, *args, **kwargs):
|
||||
serializer = FailedCheckinSerializer(
|
||||
data=self.request.data,
|
||||
context={'event': self.request.event}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
kwargs = {}
|
||||
|
||||
if not serializer.validated_data.get('position'):
|
||||
kwargs['position'] = OrderPosition.all.filter(
|
||||
secret=serializer.validated_data['raw_barcode']
|
||||
).first()
|
||||
|
||||
c = serializer.save(
|
||||
list=self.get_object(),
|
||||
successful=False,
|
||||
forced=True,
|
||||
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||
gate=self.request.auth.gate if isinstance(self.request.auth, Device) else None,
|
||||
**kwargs,
|
||||
)
|
||||
if c.position:
|
||||
c.position.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': c.position.id,
|
||||
'positionid': c.position.positionid,
|
||||
'errorcode': c.error_reason,
|
||||
'reason_explanation': c.error_explanation,
|
||||
'datetime': c.datetime,
|
||||
'type': c.type,
|
||||
'list': c.list.pk
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
else:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': c.datetime,
|
||||
'type': c.type,
|
||||
'list': c.list.pk,
|
||||
'barcode': c.raw_barcode
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
@action(detail=True, methods=['GET'])
|
||||
def status(self, *args, **kwargs):
|
||||
with language(self.request.event.settings.locale):
|
||||
@@ -294,7 +348,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
@@ -304,7 +358,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
@@ -356,6 +411,17 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
common_checkin_args = dict(
|
||||
raw_barcode=self.kwargs['pk'],
|
||||
type=type,
|
||||
list=self.checkinlist,
|
||||
datetime=dt,
|
||||
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||
gate=self.request.auth.gate if isinstance(self.request.auth, Device) else None,
|
||||
nonce=nonce,
|
||||
forced=force,
|
||||
)
|
||||
|
||||
try:
|
||||
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
@@ -371,6 +437,25 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
|
||||
for k, s in self.request.event.ticket_secret_generators.items():
|
||||
try:
|
||||
parsed = s.parse_secret(self.kwargs['pk'])
|
||||
common_checkin_args.update({
|
||||
'raw_item': parsed.item,
|
||||
'raw_variation': parsed.variation,
|
||||
'raw_subevent': parsed.subevent,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
|
||||
raise Http404()
|
||||
|
||||
op = revoked_matches[0].position
|
||||
@@ -380,6 +465,12 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
|
||||
given_answers = {}
|
||||
if 'answers' in self.request.data:
|
||||
@@ -409,6 +500,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
type=type,
|
||||
raw_barcode=None,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
@@ -424,11 +516,19 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'force': force,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=e.code,
|
||||
error_explanation=e.reason,
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
|
||||
@@ -55,9 +55,9 @@ from pretix.api.serializers.order import (
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
|
||||
TaxRule, TeamAPIToken, generate_secret,
|
||||
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
|
||||
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||
Quota, SubEvent, TaxRule, TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -201,7 +201,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to', 'seat',
|
||||
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
|
||||
)
|
||||
@@ -212,7 +213,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -781,7 +783,7 @@ with scopes_disabled():
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(checkins__isnull=not value)
|
||||
return queryset.alias(ce=Exists(Checkin.objects.filter(position=OuterRef('pk')))).filter(ce=value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
|
||||
@@ -835,7 +837,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
||||
qs = qs.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', qs.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
@@ -845,7 +848,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
||||
Prefetch(
|
||||
'positions',
|
||||
qs.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
)
|
||||
)
|
||||
))
|
||||
@@ -854,7 +858,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
|
||||
)
|
||||
|
||||
60
src/pretix/base/migrations/0192_checkin_more_fields.py
Normal file
60
src/pretix/base/migrations/0192_checkin_more_fields.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 3.2.2 on 2021-05-11 16:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0191_event_last_modified'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='error_explanation',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='error_reason',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_barcode',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_item',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_subevent',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.subevent'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_variation',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.itemvariation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
|
||||
name='successful',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='position',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='all_checkins', to='pretixbase.orderposition'),
|
||||
),
|
||||
]
|
||||
@@ -31,6 +31,7 @@
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -231,9 +232,14 @@ class CheckinList(LoggedModel):
|
||||
return rules
|
||||
|
||||
|
||||
class SuccessfulCheckinManager(ScopedManager(organizer='list__event__organizer').__class__):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(successful=True)
|
||||
|
||||
|
||||
class Checkin(models.Model):
|
||||
"""
|
||||
A check-in object is created when a person enters or exits the event.
|
||||
A check-in object is created when a ticket is scanned with our scanning apps.
|
||||
"""
|
||||
TYPE_ENTRY = 'entry'
|
||||
TYPE_EXIT = 'exit'
|
||||
@@ -241,13 +247,82 @@ class Checkin(models.Model):
|
||||
(TYPE_ENTRY, _('Entry')),
|
||||
(TYPE_EXIT, _('Exit')),
|
||||
)
|
||||
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins', on_delete=models.CASCADE)
|
||||
|
||||
REASON_CANCELED = 'canceled'
|
||||
REASON_INVALID = 'invalid'
|
||||
REASON_UNPAID = 'unpaid'
|
||||
REASON_PRODUCT = 'product'
|
||||
REASON_RULES = 'rules'
|
||||
REASON_REVOKED = 'revoked'
|
||||
REASON_INCOMPLETE = 'incomplete'
|
||||
REASON_ALREADY_REDEEMED = 'already_redeemed'
|
||||
REASON_ERROR = 'error'
|
||||
REASONS = (
|
||||
(REASON_CANCELED, _('Order canceled')),
|
||||
(REASON_INVALID, _('Unknown ticket')),
|
||||
(REASON_UNPAID, _('Ticket not paid')),
|
||||
(REASON_RULES, _('Forbidden by custom rule')),
|
||||
(REASON_REVOKED, _('Ticket code revoked/changed')),
|
||||
(REASON_INCOMPLETE, _('Information required')),
|
||||
(REASON_ALREADY_REDEEMED, _('Ticket already used')),
|
||||
(REASON_ERROR, _('Server error')),
|
||||
)
|
||||
|
||||
successful = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
error_reason = models.CharField(
|
||||
max_length=100,
|
||||
choices=REASONS,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
error_explanation = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
position = models.ForeignKey(
|
||||
'pretixbase.OrderPosition',
|
||||
related_name='all_checkins',
|
||||
on_delete=models.CASCADE,
|
||||
null=True, blank=True,
|
||||
)
|
||||
|
||||
# For "raw" scans where we do not know which position they belong to (e.g. scan of signed
|
||||
# barcode that is not in database).
|
||||
raw_barcode = models.TextField(null=True, blank=True)
|
||||
raw_item = models.ForeignKey(
|
||||
'pretixbase.Item',
|
||||
related_name='checkins',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
)
|
||||
raw_variation = models.ForeignKey(
|
||||
'pretixbase.ItemVariation',
|
||||
related_name='checkins',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
)
|
||||
raw_subevent = models.ForeignKey(
|
||||
'pretixbase.SubEvent',
|
||||
related_name='checkins',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
)
|
||||
|
||||
# Datetime of checkin, might be different from created if past scans are uploaded
|
||||
datetime = models.DateTimeField(default=now)
|
||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||
|
||||
# Datetime of creation on server
|
||||
created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
|
||||
|
||||
list = models.ForeignKey(
|
||||
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
|
||||
)
|
||||
type = models.CharField(max_length=100, choices=CHECKIN_TYPES, default=TYPE_ENTRY)
|
||||
|
||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||
forced = models.BooleanField(default=False)
|
||||
device = models.ForeignKey(
|
||||
'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True
|
||||
@@ -257,7 +332,8 @@ class Checkin(models.Model):
|
||||
)
|
||||
auto_checked_in = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='position__order__event__organizer')
|
||||
all = ScopedManager(organizer='list__event__organizer')
|
||||
objects = SuccessfulCheckinManager()
|
||||
|
||||
class Meta:
|
||||
ordering = (('-datetime'),)
|
||||
@@ -269,7 +345,8 @@ class Checkin(models.Model):
|
||||
|
||||
def save(self, **kwargs):
|
||||
super().save(**kwargs)
|
||||
self.position.order.touch()
|
||||
if self.position:
|
||||
self.position.order.touch()
|
||||
self.list.event.cache.delete('checkin_count')
|
||||
self.list.touch()
|
||||
|
||||
@@ -277,3 +354,7 @@ class Checkin(models.Model):
|
||||
super().delete(**kwargs)
|
||||
self.position.order.touch()
|
||||
self.list.touch()
|
||||
|
||||
@property
|
||||
def is_late_upload(self):
|
||||
return self.created and abs(self.created - self.datetime) > timedelta(minutes=2)
|
||||
|
||||
@@ -2054,6 +2054,14 @@ class OrderPosition(AbstractPosition):
|
||||
def sort_key(self):
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||
|
||||
@property
|
||||
def checkins(self):
|
||||
"""
|
||||
Related manager for all successful checkins. Use ``all_checkins`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_checkins(manager='objects')
|
||||
|
||||
@property
|
||||
def generate_ticket(self):
|
||||
if self.item.generate_tickets is not None:
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
import base64
|
||||
import inspect
|
||||
import struct
|
||||
from collections import namedtuple
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import Backend
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
@@ -37,6 +39,8 @@ from pretix.base.models import Item, ItemVariation, SubEvent
|
||||
from pretix.base.secretgenerators import pretix_sig1_pb2
|
||||
from pretix.base.signals import register_ticket_secret_generators
|
||||
|
||||
ParsedSecret = namedtuple('AnalyzedSecret', 'item variation subevent attendee_name opaque_id')
|
||||
|
||||
|
||||
class BaseTicketSecretGenerator:
|
||||
"""
|
||||
@@ -72,6 +76,14 @@ class BaseTicketSecretGenerator:
|
||||
"""
|
||||
return False
|
||||
|
||||
def parse_secret(self, secret: str) -> Optional[ParsedSecret]:
|
||||
"""
|
||||
Given a ``secret``, return an ``ParsedSecret`` with the information decoded from the secret, if possible.
|
||||
Any value of ``ParsedSecret`` may be ``None``, and if parsing is not possible at all, you can ``None`` (as
|
||||
the default implementation does).
|
||||
"""
|
||||
return None
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
attendee_name: str = None, current_secret: str = None, force_invalidate=False) -> str:
|
||||
"""
|
||||
@@ -181,6 +193,15 @@ class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
|
||||
except:
|
||||
return None
|
||||
|
||||
def parse_secret(self, secret: str) -> Optional[ParsedSecret]:
|
||||
ticket = self._parse(secret)
|
||||
if ticket:
|
||||
item = self.event.items.filter(pk=ticket.item).first() if ticket.item else None
|
||||
subevent = self.event.subevents.filter(pk=ticket.subevent).first() if ticket.subevent else None
|
||||
variation = item.variations.filter(pk=ticket.variation).first() if item and ticket.subevent else None
|
||||
opaque_id = ticket.seed
|
||||
return self.ParsedSecret(item=item, subevent=subevent, variation=variation, opaque_id=opaque_id, attendee_name=None)
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
current_secret: str = None, force_invalidate=False):
|
||||
if current_secret and not force_invalidate:
|
||||
|
||||
@@ -566,7 +566,8 @@ def _save_answers(op, answers, given_answers):
|
||||
|
||||
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY):
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
|
||||
raw_barcode=None):
|
||||
"""
|
||||
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
|
||||
not valid at this time.
|
||||
@@ -623,12 +624,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
_('This order is not marked as paid.'),
|
||||
'unpaid'
|
||||
)
|
||||
elif require_answers and not force and questions_supported:
|
||||
raise RequiredQuestionsError(
|
||||
_('You need to answer questions to complete this check-in.'),
|
||||
'incomplete',
|
||||
require_answers
|
||||
)
|
||||
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
|
||||
rule_data = LazyRuleVars(op, clist, dt)
|
||||
@@ -643,6 +638,13 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
if require_answers and not force and questions_supported:
|
||||
raise RequiredQuestionsError(
|
||||
_('You need to answer questions to complete this check-in.'),
|
||||
'incomplete',
|
||||
require_answers
|
||||
)
|
||||
|
||||
device = None
|
||||
if isinstance(auth, Device):
|
||||
device = auth
|
||||
@@ -668,6 +670,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
gate=device.gate if device else None,
|
||||
nonce=nonce,
|
||||
forced=force and not entry_allowed,
|
||||
raw_barcode=raw_barcode,
|
||||
)
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
@@ -676,6 +679,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'forced': force or op.order.status != Order.STATUS_PAID,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'answers': {k.pk: str(v) for k, v in given_answers.items()},
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
checkin_created.send(op.order.event, checkin=ci)
|
||||
|
||||
@@ -454,7 +454,9 @@ Arguments: ``checkin``
|
||||
|
||||
This signal is sent out every time a check-in is created (i.e. an order position is marked as
|
||||
checked in). It is not send if the position was already checked in and is force-checked-in a second time.
|
||||
The check-in object is given as the first argument
|
||||
The check-in object is given as the first argument.
|
||||
|
||||
For backwards compatibility reasons, this signal is only sent when a **successful** scan is saved.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
@@ -48,15 +48,16 @@ from django.utils.formats import date_format, localize
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelChoiceField
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.widgets import (
|
||||
DatePickerWidget, SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Checkin, Event, EventMetaProperty, EventMetaValue, Invoice, InvoiceAddress,
|
||||
Item, Order, OrderPayment, OrderPosition, OrderRefund, Organizer, Question,
|
||||
QuestionAnswer, SubEvent,
|
||||
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
|
||||
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
|
||||
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
|
||||
)
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -1736,3 +1737,127 @@ class OverviewFilterForm(FilterForm):
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
elif 'subevent':
|
||||
del self.fields['subevent']
|
||||
|
||||
|
||||
class CheckinFilterForm(FilterForm):
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All check-ins')),
|
||||
('successful', _('Successful check-ins')),
|
||||
('unsuccessful', _('Unsuccessful check-ins')),
|
||||
),
|
||||
required=False
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
label=_('Scan type'),
|
||||
choices=[
|
||||
('', _('All directions')),
|
||||
] + list(Checkin.CHECKIN_TYPES),
|
||||
required=False
|
||||
)
|
||||
itemvar = forms.ChoiceField(
|
||||
label=_("Product"),
|
||||
required=False
|
||||
)
|
||||
device = SafeModelChoiceField(
|
||||
label=_('Device'),
|
||||
empty_label=_('All devices'),
|
||||
queryset=Device.objects.none(),
|
||||
required=False
|
||||
)
|
||||
gate = SafeModelChoiceField(
|
||||
label=_('Gate'),
|
||||
empty_label=_('All gates'),
|
||||
queryset=Gate.objects.none(),
|
||||
required=False
|
||||
)
|
||||
checkin_list = SafeModelChoiceField(queryset=CheckinList.objects.none(), required=False) # overridden later
|
||||
datetime_from = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
}),
|
||||
label=pgettext_lazy('filter', 'Start date'),
|
||||
required=False,
|
||||
)
|
||||
datetime_until = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
}),
|
||||
label=pgettext_lazy('filter', 'End date'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['device'].queryset = self.event.organizer.devices.all()
|
||||
self.fields['gate'].queryset = self.event.organizer.gates.all()
|
||||
|
||||
self.fields['checkin_list'].queryset = self.event.checkin_lists.all()
|
||||
self.fields['checkin_list'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:event.orders.checkinlists.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Check-in list'),
|
||||
}
|
||||
)
|
||||
self.fields['checkin_list'].widget.choices = self.fields['checkin_list'].choices
|
||||
self.fields['checkin_list'].label = _('Check-in list')
|
||||
|
||||
choices = [('', _('All products'))]
|
||||
for i in self.event.items.prefetch_related('variations').all():
|
||||
variations = list(i.variations.all())
|
||||
if variations:
|
||||
choices.append((str(i.pk), _('{product} – Any variation').format(product=i.name)))
|
||||
for v in variations:
|
||||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (i.name, v.value)))
|
||||
else:
|
||||
choices.append((str(i.pk), i.name))
|
||||
self.fields['itemvar'].choices = choices
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
if fdata.get('status'):
|
||||
s = fdata.get('status')
|
||||
if s == 'successful':
|
||||
qs = qs.filter(successful=True)
|
||||
elif s == 'unsuccessful':
|
||||
qs = qs.filter(successful=False)
|
||||
|
||||
if fdata.get('type'):
|
||||
qs = qs.filter(type=fdata.get('type'))
|
||||
|
||||
if fdata.get('itemvar'):
|
||||
if '-' in fdata.get('itemvar'):
|
||||
qs = qs.alias(
|
||||
item_id=Coalesce('raw_item_id', 'position__item_id'),
|
||||
variation_id=Coalesce('raw_variation_id', 'position__variation_id'),
|
||||
).filter(
|
||||
item_id=fdata.get('itemvar').split('-')[0],
|
||||
variation_id=fdata.get('itemvar').split('-')[1]
|
||||
)
|
||||
else:
|
||||
qs = qs.alias(
|
||||
item_id=Coalesce('raw_item_id', 'position__item_id'),
|
||||
).filter(item_id=fdata.get('itemvar'))
|
||||
|
||||
if fdata.get('device'):
|
||||
qs = qs.filter(device_id=fdata.get('device').pk)
|
||||
|
||||
if fdata.get('gate'):
|
||||
qs = qs.filter(gate_id=fdata.get('gate').pk)
|
||||
|
||||
if fdata.get('checkin_list'):
|
||||
qs = qs.filter(list_id=fdata.get('checkin_list').pk)
|
||||
|
||||
if fdata.get('datetime_from'):
|
||||
qs = qs.filter(datetime__gte=fdata.get('datetime_from'))
|
||||
|
||||
if fdata.get('datetime_until'):
|
||||
qs = qs.filter(datetime__lte=fdata.get('datetime_until'))
|
||||
|
||||
return qs
|
||||
|
||||
@@ -297,7 +297,15 @@ def get_event_navigation(request: HttpRequest):
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': 'event.orders.checkin' in url.url_name,
|
||||
'active': 'event.orders.checkinlists' in url.url_name,
|
||||
},
|
||||
{
|
||||
'label': _('Check-in history'),
|
||||
'url': reverse('control:event.orders.checkins', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': 'event.orders.checkins' in url.url_name,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
170
src/pretix/control/templates/pretixcontrol/checkin/checkins.html
Normal file
170
src/pretix/control/templates/pretixcontrol/checkin/checkins.html
Normal file
@@ -0,0 +1,170 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Check-in history" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Check-in history" %}</h1>
|
||||
<form class="" action="" method="get">
|
||||
<div class="row filter-form">
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.checkin_list layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.status layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.type layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.device layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.datetime_from layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.datetime_until layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.gate layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.itemvar layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
<button class="btn btn-block btn-primary" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
<span class="hidden-md">{% trans "Filter" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if checkins|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% if request.GET %}
|
||||
{% trans "Your search did not match any check-ins." %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed %}
|
||||
You haven't scanned any tickets yet.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Time of scan" %}</th>
|
||||
<th>{% trans "Scan type" %}<br>{% trans "Check-in list" %}</th>
|
||||
<th>{% trans "Result" %}</th>
|
||||
<th>{% trans "Ticket" %}<br>{% trans "Product" %}</th>
|
||||
<th>{% trans "Device" %}<br>{% trans "Gate" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in checkins %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if c.type == "exit" %}
|
||||
{% if c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
||||
{% endif %}
|
||||
{% elif c.forced and c.successful %}
|
||||
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
|
||||
{% elif c.forced and not c.successful %}
|
||||
<br>
|
||||
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
|
||||
{% elif c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-magic" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.type == "exit" %}<span class="fa fa-fw fa-sign-out"></span>{% endif %}
|
||||
{% if c.type == "entry" %}<span class="fa fa-fw fa-sign-in"></span>{% endif %}
|
||||
{{ c.get_type_display }}
|
||||
<br>
|
||||
<small>
|
||||
<a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=c.list.id %}">{{ c.list }}</a>
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if c.successful %}
|
||||
<span class="label label-success">
|
||||
<span class="fa fa-fw fa-check"></span> {% trans "Successful" context "checkin_result" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="label label-danger">
|
||||
<span class="fa fa-fw fa-exclamation-triangle"></span>
|
||||
{% trans "Denied" context "checkin_result" %}
|
||||
</span>
|
||||
<br>
|
||||
<small>
|
||||
{{ c.get_error_reason_display }}
|
||||
{% if c.error_explanation %}
|
||||
<br>
|
||||
{{ c.error_explanation }}
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.position %}
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
<strong>
|
||||
<a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=c.position.order.code %}">{{ c.position.order.code }}</a>-{{ c.position.positionid }}
|
||||
</strong>
|
||||
{% if c.position.attendee_name %}
|
||||
<br>
|
||||
<small>
|
||||
{{ c.position.attendee_name }}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% if c.position.item %}
|
||||
<br>
|
||||
<small>
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=c.position.id %}">
|
||||
{{ c.position.item }}{% if c.position.variation %} –
|
||||
{{ c.position.variation }}{% endif %}
|
||||
</a>
|
||||
</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="fa fa-qrcode fa-fw"></span>
|
||||
<span title="{{ c.raw_barcode }}">
|
||||
{{ c.raw_barcode|slice:":16" }}{% if c.raw_barcode|length > 16 %}…{% endif %}
|
||||
</span>
|
||||
{% if c.raw_item %}
|
||||
<br>
|
||||
<small>
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=c.raw_item.id %}">
|
||||
{{ c.raw_item }}{% if c.raw_variation %} – {{ c.raw_variation }}{% endif %}
|
||||
</a>
|
||||
</small>
|
||||
{% endif %}
|
||||
{% if c.raw_subevent %}
|
||||
<br>
|
||||
<small>
|
||||
{{ c.raw_subevent }}{% if c.raw_variation %} – {{ c.raw_variation }}{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ c.device|default:"" }}
|
||||
{% if c.gate %}
|
||||
<br><small>{{ c.gate }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -324,19 +324,21 @@
|
||||
– {{ line.variation }}
|
||||
{% endif %}
|
||||
{% if line.checkins.all %}
|
||||
{% for c in line.checkins.all %}
|
||||
{% if c.type == "exit" %}
|
||||
{% for c in line.all_checkins.all %}
|
||||
{% if not c.successful %}
|
||||
<span class="fa fa-fw fa-exclamation-circle text-danger" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Denied scan: {{ date }}{% endblocktrans %}<br>{{ c.get_error_reason_display }}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
{% elif c.type == "exit" %}
|
||||
{% if c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
||||
<span class="fa fa-fw text-success fa-hourglass-end" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
||||
{% else %}
|
||||
<span class="fa fa-fw fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
<span class="fa fa-fw text-success fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
{% endif %}
|
||||
{% elif c.forced %}
|
||||
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
<span class="fa fa-fw fa-warning text-warning" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
{% elif c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-magic" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
<span class="fa fa-fw fa-magic text-success" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
{% else %}
|
||||
<span class="fa fa-fw fa-check" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
<span class="fa fa-fw fa-check text-success" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -364,6 +364,7 @@ urlpatterns = [
|
||||
re_path(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'),
|
||||
re_path(r'^waitinglist/(?P<entry>\d+)/delete$', waitinglist.EntryDelete.as_view(),
|
||||
name='event.orders.waitinglist.delete'),
|
||||
re_path(r'^checkins/$', checkin.CheckinListView.as_view(), name='event.orders.checkins'),
|
||||
re_path(r'^checkinlists/$', checkin.CheckinListList.as_view(), name='event.orders.checkinlists'),
|
||||
re_path(r'^checkinlists/add$', checkin.CheckinListCreate.as_view(), name='event.orders.checkinlists.add'),
|
||||
re_path(r'^checkinlists/select2$', typeahead.checkinlist_select2, name='event.orders.checkinlists.select2'),
|
||||
|
||||
@@ -50,7 +50,7 @@ from pretix.base.models import Checkin, Order, OrderPosition
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
from pretix.base.signals import checkin_created
|
||||
from pretix.control.forms.checkin import CheckinListForm
|
||||
from pretix.control.forms.filter import CheckInFilterForm
|
||||
from pretix.control.forms.filter import CheckInFilterForm, CheckinFilterForm
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.views import CreateView, PaginationMixin, UpdateView
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -371,3 +371,31 @@ class CheckinListDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
|
||||
class CheckinListView(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
model = Checkin
|
||||
context_object_name = 'checkins'
|
||||
permission = 'can_view_orders'
|
||||
template_name = 'pretixcontrol/checkin/checkins.html'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Checkin.all.filter(
|
||||
list__event=self.request.event,
|
||||
).select_related(
|
||||
'position', 'position', 'position__item', 'position__variation', 'position__subevent'
|
||||
).prefetch_related(
|
||||
'list', 'gate'
|
||||
)
|
||||
if self.filter_form.is_valid():
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
return qs
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return CheckinFilterForm(data=self.request.GET, event=self.request.event)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['filter_form'] = self.filter_form
|
||||
return ctx
|
||||
|
||||
@@ -340,7 +340,7 @@ class OrderDetail(OrderView):
|
||||
).prefetch_related(
|
||||
'item__questions', 'issued_gift_cards',
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related('list').order_by('datetime')),
|
||||
Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')),
|
||||
).order_by('positionid')
|
||||
|
||||
positions = []
|
||||
|
||||
@@ -596,7 +596,7 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
|
||||
class CheckinLogList(ListExporter):
|
||||
name = "checkinlog"
|
||||
identifier = 'checkinlog'
|
||||
verbose_name = gettext_lazy('Check-in log (all successful scans)')
|
||||
verbose_name = gettext_lazy('Check-in log (all scans)')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -616,42 +616,53 @@ class CheckinLogList(ListExporter):
|
||||
_('Device'),
|
||||
_('Offline override'),
|
||||
_('Automatically checked in'),
|
||||
_('Gate'),
|
||||
_('Result'),
|
||||
_('Error message'),
|
||||
]
|
||||
|
||||
qs = Checkin.objects.filter(
|
||||
position__order__event=self.event,
|
||||
qs = Checkin.all.filter(
|
||||
list__event=self.event,
|
||||
)
|
||||
if form_data.get('list'):
|
||||
qs = qs.filter(list_id=form_data.get('list'))
|
||||
if form_data.get('items'):
|
||||
qs = qs.filter(position__item_id__in=form_data['items'])
|
||||
if len(form_data['items']) != self.event.items.count():
|
||||
qs = qs.filter(Q(position__item_id__in=form_data['items']) | Q(raw_item_id__in=form_data['items']))
|
||||
if form_data.get('successful_only'):
|
||||
qs = qs.filter(successful=True)
|
||||
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
|
||||
qs = qs.select_related(
|
||||
'position__item', 'position__order', 'position__order__invoice_address', 'position', 'list', 'device'
|
||||
'position__item', 'position__order', 'position__order__invoice_address', 'position', 'list', 'device',
|
||||
'raw_item'
|
||||
).order_by(
|
||||
'datetime'
|
||||
)
|
||||
for ci in qs.iterator():
|
||||
try:
|
||||
ia = ci.position.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress()
|
||||
if ci.position:
|
||||
try:
|
||||
ia = ci.position.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress()
|
||||
|
||||
yield [
|
||||
date_format(ci.datetime.astimezone(self.timezone), 'SHORT_DATE_FORMAT'),
|
||||
date_format(ci.datetime.astimezone(self.timezone), 'TIME_FORMAT'),
|
||||
str(ci.list),
|
||||
ci.get_type_display(),
|
||||
ci.position.order.code,
|
||||
ci.position.positionid,
|
||||
ci.position.secret,
|
||||
str(ci.position.item),
|
||||
ci.position.attendee_name or ia.name,
|
||||
str(ci.device),
|
||||
ci.position.order.code if ci.position else '',
|
||||
ci.position.positionid if ci.position else '',
|
||||
ci.raw_barcode or ci.position.secret,
|
||||
str(ci.position.item) if ci.position else (str(ci.raw_item) if ci.raw_item else ''),
|
||||
(ci.position.attendee_name or ia.name) if ci.position else '',
|
||||
str(ci.device) if ci.device else '',
|
||||
_('Yes') if ci.forced else _('No'),
|
||||
_('Yes') if ci.auto_checked_in else _('No'),
|
||||
str(ci.gate or ''),
|
||||
_('OK') if ci.successful else ci.get_error_reason_display(),
|
||||
ci.error_explanation or ''
|
||||
]
|
||||
|
||||
def get_filename(self):
|
||||
@@ -678,6 +689,12 @@ class CheckinLogList(ListExporter):
|
||||
),
|
||||
initial=self.event.items.all()
|
||||
)),
|
||||
('successful_only',
|
||||
forms.BooleanField(
|
||||
label=_('Successful scans only'),
|
||||
initial=True,
|
||||
required=False,
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ from django.views.generic import DeleteView, FormView, ListView
|
||||
|
||||
from pretix.base.email import get_available_placeholders
|
||||
from pretix.base.i18n import LazyI18nString, language
|
||||
from pretix.base.models import LogEntry, Order, OrderPosition
|
||||
from pretix.base.models import Checkin, LogEntry, Order, OrderPosition
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.mail import TolerantDict
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
@@ -141,12 +141,28 @@ class SenderView(EventPermissionRequiredMixin, FormView):
|
||||
|
||||
if form.cleaned_data.get('filter_checkins'):
|
||||
ql = []
|
||||
|
||||
if form.cleaned_data.get('not_checked_in'):
|
||||
ql.append(Q(checkins__list_id=None))
|
||||
opq = opq.alias(
|
||||
any_checkins=Exists(
|
||||
Checkin.all.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
successful=True
|
||||
)
|
||||
)
|
||||
)
|
||||
ql.append(Q(any_checkins=False))
|
||||
if form.cleaned_data.get('checkin_lists'):
|
||||
ql.append(Q(
|
||||
checkins__list_id__in=[i.pk for i in form.cleaned_data.get('checkin_lists', [])],
|
||||
))
|
||||
opq = opq.alias(
|
||||
matching_checkins=Exists(
|
||||
Checkin.all.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id__in=[i.pk for i in form.cleaned_data.get('checkin_lists', [])],
|
||||
successful=True
|
||||
)
|
||||
)
|
||||
)
|
||||
ql.append(Q(matching_checkins=True))
|
||||
if len(ql) == 2:
|
||||
opq = opq.filter(ql[0] | ql[1])
|
||||
elif ql:
|
||||
|
||||
@@ -53,7 +53,7 @@ window.vapp = new Vue({
|
||||
'result.exit': gettext('Exit recorded'),
|
||||
'result.already_redeemed': gettext('Ticket already used'),
|
||||
'result.questions': gettext('Information required'),
|
||||
'result.invalid': gettext('Invalid ticket'),
|
||||
'result.invalid': gettext('Unknown ticket'),
|
||||
'result.product': gettext('Invalid product'),
|
||||
'result.unpaid': gettext('Ticket not paid'),
|
||||
'result.rules': gettext('Entry not allowed'),
|
||||
|
||||
@@ -432,6 +432,8 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a
|
||||
'list': clist_all.pk,
|
||||
'datetime': c.datetime.isoformat().replace('+00:00', 'Z'),
|
||||
'auto_checked_in': False,
|
||||
'device': None,
|
||||
'gate': None,
|
||||
'type': 'entry',
|
||||
}
|
||||
]
|
||||
@@ -472,6 +474,8 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a
|
||||
'list': clist_all.pk,
|
||||
'datetime': c.datetime.isoformat().replace('+00:00', 'Z'),
|
||||
'auto_checked_in': False,
|
||||
'device': None,
|
||||
'gate': None,
|
||||
'type': 'entry',
|
||||
}
|
||||
]
|
||||
@@ -1060,3 +1064,45 @@ def test_question_upload(token_client, organizer, clist, event, order, question)
|
||||
with scopes_disabled():
|
||||
assert order.positions.first().answers.get(question=question[0]).answer.startswith('file://')
|
||||
assert order.positions.first().answers.get(question=question[0]).file
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_store_failed(token_client, organizer, clist, event, order):
|
||||
with scopes_disabled():
|
||||
p = order.positions.first()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
|
||||
organizer.slug, event.slug, clist.pk,
|
||||
), {
|
||||
'raw_barcode': '123456',
|
||||
'error_reason': 'invalid'
|
||||
}, format='json')
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
assert Checkin.all.filter(successful=False).exists()
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
|
||||
organizer.slug, event.slug, clist.pk,
|
||||
), {
|
||||
'raw_barcode': '123456',
|
||||
'position': p.pk,
|
||||
'error_reason': 'unpaid'
|
||||
}, format='json')
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
assert p.all_checkins.filter(successful=False).count() == 1
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
|
||||
organizer.slug, event.slug, clist.pk,
|
||||
), {
|
||||
'position': p.pk,
|
||||
'error_reason': 'unpaid'
|
||||
}, format='json')
|
||||
assert resp.status_code == 400
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
|
||||
organizer.slug, event.slug, clist.pk,
|
||||
), {
|
||||
'raw_barcode': '123456',
|
||||
'error_reason': 'unknown'
|
||||
}, format='json')
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -865,11 +865,14 @@ def test_orderposition_list(token_client, organizer, event, order, item, subeven
|
||||
with scopes_disabled():
|
||||
cl = event.checkin_lists.create(name="Default")
|
||||
c = op.checkins.create(datetime=datetime.datetime(2017, 12, 26, 10, 0, 0, tzinfo=UTC), list=cl)
|
||||
res['checkins'] = [{
|
||||
op.checkins.create(datetime=datetime.datetime(2017, 12, 26, 10, 0, 0, tzinfo=UTC), list=cl, successful=False)
|
||||
res['checkins'] = [{ # successful only
|
||||
'id': c.pk,
|
||||
'datetime': '2017-12-26T10:00:00Z',
|
||||
'list': cl.pk,
|
||||
'auto_checked_in': False,
|
||||
'device': None,
|
||||
'gate': None,
|
||||
'type': 'entry'
|
||||
}]
|
||||
resp = token_client.get(
|
||||
|
||||
@@ -157,10 +157,13 @@ event_permission_sub_urls = [
|
||||
('post', 'can_change_orders', 'orders/ABC12/refunds/1/process/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/refunds/1/done/', 404),
|
||||
('get', 'can_view_orders', 'checkinlists/', 200),
|
||||
('post', 'can_change_orders', 'checkinlists/1/failed_checkins/', 400),
|
||||
('post', 'can_change_event_settings', 'checkinlists/', 400),
|
||||
('put', 'can_change_event_settings', 'checkinlists/1/', 404),
|
||||
('patch', 'can_change_event_settings', 'checkinlists/1/', 404),
|
||||
('delete', 'can_change_event_settings', 'checkinlists/1/', 404),
|
||||
('get', 'can_view_orders', 'checkinlists/1/positions/', 404),
|
||||
('post', 'can_change_orders', 'checkinlists/1/positions/3/redeem/', 404),
|
||||
('post', 'can_create_events', 'clone/', 400),
|
||||
('get', 'can_view_orders', 'cartpositions/', 200),
|
||||
('get', 'can_view_orders', 'cartpositions/1/', 404),
|
||||
|
||||
@@ -157,6 +157,7 @@ event_urls = [
|
||||
"orders/ABC/",
|
||||
"orders/",
|
||||
"orders/import/",
|
||||
"checkins/",
|
||||
"checkinlists/",
|
||||
"checkinlists/1/",
|
||||
"checkinlists/1/change",
|
||||
@@ -349,6 +350,7 @@ event_permission_urls = [
|
||||
("can_view_orders", "waitinglist/", 200),
|
||||
("can_change_orders", "waitinglist/auto_assign", 405),
|
||||
("can_change_orders", "waitinglist/action", 405),
|
||||
("can_view_orders", "checkins/", 200),
|
||||
("can_view_orders", "checkinlists/", 200),
|
||||
("can_view_orders", "checkinlists/1/", 404),
|
||||
("can_change_event_settings", "checkinlists/add", 200),
|
||||
|
||||
@@ -427,6 +427,7 @@ def test_sendmail_attendee_checkin_filter(logged_in_client, sendmail_url, event,
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
print(response.rendered_content)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == ['attendee1@dummy.test']
|
||||
|
||||
Reference in New Issue
Block a user