diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index e499f3780..70101e461 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -362,6 +362,42 @@ Endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/failed_checkins/ + + Stores a failed check-in. Only necessary for statistical purposes if you perform scan validation offline. + + : timedelta(minutes=2) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index f2361f480..71669d90c 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -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: diff --git a/src/pretix/base/secrets.py b/src/pretix/base/secrets.py index 84e8c1075..89c8e0d67 100644 --- a/src/pretix/base/secrets.py +++ b/src/pretix/base/secrets.py @@ -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: diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 465e7b9d0..0902ff979 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -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) diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 634a1ea41..19010ae23 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -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. """ diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 5edc02f20..6a7415b33 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -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 diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 2137324de..9cd416037 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -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, }, ] }) diff --git a/src/pretix/control/templates/pretixcontrol/checkin/checkins.html b/src/pretix/control/templates/pretixcontrol/checkin/checkins.html new file mode 100644 index 000000000..c9cd9b973 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/checkin/checkins.html @@ -0,0 +1,170 @@ +{% extends "pretixcontrol/items/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Check-in history" %}{% endblock %} +{% block inside %} +

{% trans "Check-in history" %}

+
+
+
+ {% bootstrap_field filter_form.checkin_list layout='inline' %} +
+
+ {% bootstrap_field filter_form.status layout='inline' %} +
+
+ {% bootstrap_field filter_form.type layout='inline' %} +
+
+ {% bootstrap_field filter_form.device layout='inline' %} +
+
+ {% bootstrap_field filter_form.datetime_from layout='inline' %} +
+
+ {% bootstrap_field filter_form.datetime_until layout='inline' %} +
+
+ {% bootstrap_field filter_form.gate layout='inline' %} +
+
+ {% bootstrap_field filter_form.itemvar layout='inline' %} +
+
+ +
+
+
+ {% if checkins|length == 0 %} +
+

+ {% if request.GET %} + {% trans "Your search did not match any check-ins." %} + {% else %} + {% blocktrans trimmed %} + You haven't scanned any tickets yet. + {% endblocktrans %} + {% endif %} +

+
+ {% else %} +
+ + + + + + + + + + + + {% for c in checkins %} + + + + + + + + {% endfor %} + +
{% trans "Time of scan" %}{% trans "Scan type" %}
{% trans "Check-in list" %}
{% trans "Result" %}{% trans "Ticket" %}
{% trans "Product" %}
{% trans "Device" %}
{% trans "Gate" %}
+ {{ c.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if c.type == "exit" %} + {% if c.auto_checked_in %} + + {% endif %} + {% elif c.forced and c.successful %} + + {% elif c.forced and not c.successful %} +
+ {% trans "Failed in offline mode" %} + {% elif c.auto_checked_in %} + + {% endif %} +
+ {% if c.type == "exit" %}{% endif %} + {% if c.type == "entry" %}{% endif %} + {{ c.get_type_display }} +
+ + {{ c.list }} + +
+ {% if c.successful %} + + {% trans "Successful" context "checkin_result" %} + + {% else %} + + + {% trans "Denied" context "checkin_result" %} + +
+ + {{ c.get_error_reason_display }} + {% if c.error_explanation %} +
+ {{ c.error_explanation }} + {% endif %} +
+ {% endif %} +
+ {% if c.position %} + + + {{ c.position.order.code }}-{{ c.position.positionid }} + + {% if c.position.attendee_name %} +
+ + {{ c.position.attendee_name }} + + {% endif %} + {% if c.position.item %} +
+ + + {{ c.position.item }}{% if c.position.variation %} – + {{ c.position.variation }}{% endif %} + + + {% endif %} + {% else %} + + + {{ c.raw_barcode|slice:":16" }}{% if c.raw_barcode|length > 16 %}…{% endif %} + + {% if c.raw_item %} +
+ + + {{ c.raw_item }}{% if c.raw_variation %} – {{ c.raw_variation }}{% endif %} + + + {% endif %} + {% if c.raw_subevent %} +
+ + {{ c.raw_subevent }}{% if c.raw_variation %} – {{ c.raw_variation }}{% endif %} + + {% endif %} + {% endif %} +
+ {{ c.device|default:"" }} + {% if c.gate %} +
{{ c.gate }} + {% endif %} +
+
+ {% endif %} + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index f2aacc751..5ebaa0d42 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -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 %} + + {% elif c.type == "exit" %} {% if c.auto_checked_in %} - + {% else %} - + {% endif %} {% elif c.forced %} - + {% elif c.auto_checked_in %} - + {% else %} - + {% endif %} {% endfor %} {% endif %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 782535a0f..99a62dd44 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -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\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'), diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index ca090bca0..0af0906d0 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -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 diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index c46311a60..cb2e06493 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -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 = [] diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py index d82236b34..8cad98095 100644 --- a/src/pretix/plugins/checkinlists/exporters.py +++ b/src/pretix/plugins/checkinlists/exporters.py @@ -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, + )), ] ) diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index 922bc7857..c2d432f20 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -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: diff --git a/src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js b/src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js index 6af495c56..dc6aebe91 100644 --- a/src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js +++ b/src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js @@ -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'), diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index abef933e2..aaaae3587 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -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 diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 48071d354..399d52d9b 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -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( diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 8523cdc65..53f62ebf7 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -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), diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index cf7ac28d9..f3e5b71e0 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -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), diff --git a/src/tests/plugins/sendmail/test_sendmail.py b/src/tests/plugins/sendmail/test_sendmail.py index 91c5cfccf..699067824 100644 --- a/src/tests/plugins/sendmail/test_sendmail.py +++ b/src/tests/plugins/sendmail/test_sendmail.py @@ -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']