From b301d2048835ea17fc88b4a6274e6b3a65254a36 Mon Sep 17 00:00:00 2001
From: jlwt90
Date: Tue, 9 May 2017 00:31:37 +0900
Subject: [PATCH] Fix #297 -- pretixdroid: Show metrics in the control panel
(#481)
* add checkin status page
add dashboard widget
add checkin page under orders
* modify checkin logic
added new fields in checkin page
added filter items
* add tests for checkins & minor improvement
* support addin_product & noadm setting logic
* remove name ordering check test case
---
.../pretixcontrol/checkin/index.html | 95 +++++
.../templates/pretixcontrol/event/base.html | 6 +
src/pretix/control/urls.py | 5 +-
src/pretix/control/views/checkin.py | 79 ++++
src/pretix/control/views/dashboards.py | 21 +
src/tests/control/test_checkins.py | 375 ++++++++++++++++++
src/tests/control/test_permissions.py | 1 +
7 files changed, 580 insertions(+), 2 deletions(-)
create mode 100644 src/pretix/control/templates/pretixcontrol/checkin/index.html
create mode 100644 src/pretix/control/views/checkin.py
create mode 100644 src/tests/control/test_checkins.py
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/index.html b/src/pretix/control/templates/pretixcontrol/checkin/index.html
new file mode 100644
index 0000000000..e7ad876d8b
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/checkin/index.html
@@ -0,0 +1,95 @@
+{% extends "pretixcontrol/event/base.html" %}
+{% load i18n %}
+{% load eventurl %}
+{% load urlreplace %}
+{% block title %}{% trans "Check-ins" %}{% endblock %}
+{% block content %}
+ {% trans "Check-ins" %}
+
+
+
+ {% if entries|length == 0 %}
+
+
+ {% blocktrans trimmed %}
+ No check-in record was found.
+ {% endblocktrans %}
+
+
+ {% else %}
+ {% include "pretixcontrol/pagination.html" %}
+
+ {% endif %}
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/base.html b/src/pretix/control/templates/pretixcontrol/event/base.html
index bda5526e49..5c0081b8f9 100644
--- a/src/pretix/control/templates/pretixcontrol/event/base.html
+++ b/src/pretix/control/templates/pretixcontrol/event/base.html
@@ -90,6 +90,12 @@
{% trans "Waiting list" %}
+
+
+ {% trans "Check-ins" %}
+
+
{% endif %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 3d2be5ad2c..f5479dc909 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -1,8 +1,8 @@
from django.conf.urls import include, url
from pretix.control.views import (
- auth, dashboards, event, global_settings, item, main, orders, organizer,
- user, vouchers, waitinglist,
+ auth, checkin, dashboards, event, global_settings, item, main, orders,
+ organizer, user, vouchers, waitinglist,
)
urlpatterns = [
@@ -138,5 +138,6 @@ urlpatterns = [
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'),
url(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'),
+ url(r'^checkins/$', checkin.CheckInView.as_view(), name='event.orders.checkins'),
])),
]
diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py
new file mode 100644
index 0000000000..911681b292
--- /dev/null
+++ b/src/pretix/control/views/checkin.py
@@ -0,0 +1,79 @@
+from django.db.models import Prefetch, Q
+from django.db.models.functions import Coalesce
+from django.views.generic import ListView
+
+from pretix.base.models import Checkin, Item, OrderPosition
+from pretix.control.permissions import EventPermissionRequiredMixin
+
+
+class CheckInView(EventPermissionRequiredMixin, ListView):
+ model = Checkin
+ context_object_name = 'entries'
+ paginate_by = 30
+ template_name = 'pretixcontrol/checkin/index.html'
+ permission = 'can_view_orders'
+
+ def get_queryset(self):
+
+ qs = OrderPosition.objects.filter(order__event=self.request.event, order__status='p')
+
+ # if this setting is False, we check only items for admission
+ if not self.request.event.settings.ticket_download_nonadm:
+ qs = qs.filter(item__admission=True)
+
+ if self.request.GET.get("status", "") != "":
+ p = self.request.GET.get("status", "")
+ if p == '1':
+ # records with check-in record
+ qs = qs.filter(checkins__isnull=False)
+ elif p == '0':
+ qs = qs.filter(checkins__isnull=True)
+
+ if self.request.GET.get("user", "") != "":
+ u = self.request.GET.get("user", "")
+ qs = qs.filter(
+ Q(order__email__icontains=u) | Q(attendee_name__icontains=u) | Q(attendee_email__icontains=u)
+ )
+
+ if self.request.GET.get("item", "") != "":
+ u = self.request.GET.get("item", "")
+ qs = qs.filter(item_id__in=(u,))
+
+ qs = qs.prefetch_related(
+ Prefetch('checkins', queryset=Checkin.objects.filter(position__order__event=self.request.event))
+ ).select_related('order', 'item', 'addon_to')
+
+ if self.request.GET.get("ordering", "") != "":
+ p = self.request.GET.get("ordering", "")
+ keys_allowed = self.get_ordering_keys_mappings()
+ if p in keys_allowed:
+ mapped_field = keys_allowed[p]
+ if type(mapped_field) is tuple:
+ qs = qs.annotate(**mapped_field[1]).order_by(mapped_field[0])
+ else:
+ qs = qs.order_by(mapped_field)
+
+ return qs.distinct()
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['items'] = Item.objects.filter(event=self.request.event)
+ ctx['filtered'] = ("status" in self.request.GET or "user" in self.request.GET or "item" in self.request.GET)
+ return ctx
+
+ @staticmethod
+ def get_ordering_keys_mappings():
+ return {
+ 'code': 'order__code',
+ '-code': '-order__code',
+ 'email': 'order__email',
+ '-email': '-order__email',
+ 'status': 'checkins__id',
+ '-status': '-checkins__id',
+ 'timestamp': 'checkins__datetime',
+ '-timestamp': '-checkins__datetime',
+ 'item': 'item__name',
+ '-item': '-item__name',
+ 'name': ('display_name', {'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}),
+ '-name': ('-display_name', {'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}),
+ }
diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py
index ad3fdb0bcb..1fbd10fe9a 100644
--- a/src/pretix/control/views/dashboards.py
+++ b/src/pretix/control/views/dashboards.py
@@ -168,6 +168,27 @@ def shop_state_widget(sender, **kwargs):
}]
+@receiver(signal=event_dashboard_widgets)
+def checkin_widget(sender, **kwargs):
+ size_qs = OrderPosition.objects.filter(order__event=sender, order__status='p')
+ checked_qs = OrderPosition.objects.filter(order__event=sender, order__status='p', checkins__isnull=False)
+
+ # if this setting is False, we check only items for admission
+ if not sender.settings.ticket_download_nonadm:
+ size_qs = size_qs.filter(item__admission=True)
+ checked_qs = checked_qs.filter(item__admission=True)
+
+ return [{
+ 'content': NUM_WIDGET.format(num='{}/{}'.format(checked_qs.count(), size_qs.count()), text=_('Checked in')),
+ 'display_size': 'small',
+ 'priority': 50,
+ 'url': reverse('control:event.orders.checkins', kwargs={
+ 'event': sender.slug,
+ 'organizer': sender.organizer.slug
+ })
+ }]
+
+
@receiver(signal=event_dashboard_widgets)
def welcome_wizard_widget(sender, **kwargs):
template = get_template('pretixcontrol/event/dashboard_widget_welcome.html')
diff --git a/src/tests/control/test_checkins.py b/src/tests/control/test_checkins.py
new file mode 100644
index 0000000000..41095b1034
--- /dev/null
+++ b/src/tests/control/test_checkins.py
@@ -0,0 +1,375 @@
+from datetime import timedelta
+from decimal import Decimal
+
+import pytest
+from django.utils.timezone import now
+
+from pretix.base.models import (
+ Checkin, Event, Item, ItemAddOn, ItemCategory, Order, OrderPosition,
+ Organizer, Team, User,
+)
+from pretix.control.views.dashboards import checkin_widget
+
+
+@pytest.fixture
+def dashboard_env():
+ o = Organizer.objects.create(name='Dummy', slug='dummy')
+ event = Event.objects.create(
+ organizer=o, name='Dummy', slug='dummy',
+ date_from=now(), plugins='pretix.plugins.banktransfer,tests.testdummy'
+ )
+ event.settings.set('ticketoutput_testdummy__enabled', True)
+ user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
+ item_ticket = Item.objects.create(event=event, name="Ticket", default_price=23, admission=True)
+ item_mascot = Item.objects.create(event=event, name="Mascot", default_price=10, admission=False)
+
+ t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True)
+ t.members.add(user)
+ t.limit_events.add(event)
+
+ event.settings.set('attendee_names_asked', True)
+ event.settings.set('locales', ['en', 'de'])
+
+ order_paid = Order.objects.create(
+ code='FOO', event=event, email='dummy@dummy.test',
+ status=Order.STATUS_PAID,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=33, payment_provider='banktransfer', locale='en'
+ )
+ OrderPosition.objects.create(
+ order=order_paid,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="Peter"
+ )
+ OrderPosition.objects.create(
+ order=order_paid,
+ item=item_mascot,
+ variation=None,
+ price=Decimal("10")
+ )
+
+ return event, user, o, order_paid, item_ticket, item_mascot
+
+
+@pytest.mark.django_db
+def test_dashboard(dashboard_env):
+ c = checkin_widget(dashboard_env[0])
+ assert '0/2' in c[0]['content']
+
+
+@pytest.mark.django_db
+def test_dashboard_pending_not_count(dashboard_env):
+ c = checkin_widget(dashboard_env[0])
+ order_pending = Order.objects.create(
+ code='FOO', event=dashboard_env[0], email='dummy@dummy.test',
+ status=Order.STATUS_PENDING,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=23, payment_provider='banktransfer', locale='en'
+ )
+ OrderPosition.objects.create(
+ order=order_pending,
+ item=dashboard_env[4],
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="NotPaid"
+ )
+ assert '0/2' in c[0]['content']
+
+
+@pytest.mark.django_db
+def test_dashboard_with_checkin(dashboard_env):
+ op = OrderPosition.objects.get(
+ order=dashboard_env[3],
+ item=dashboard_env[4]
+ )
+ Checkin.objects.create(position=op)
+ c = checkin_widget(dashboard_env[0])
+ assert '1/2' in c[0]['content']
+
+
+@pytest.mark.django_db
+def test_dashboard_exclude_non_admission_item(dashboard_env):
+ dashboard_env[0].settings.ticket_download_nonadm = False
+ dashboard_env[0].save()
+ c = checkin_widget(dashboard_env[0])
+ assert '0/1' in c[0]['content']
+
+
+@pytest.mark.django_db
+def test_dashboard_exclude_non_admission_item_with_checkin(dashboard_env):
+ dashboard_env[0].settings.ticket_download_nonadm = False
+ dashboard_env[0].save()
+ op = OrderPosition.objects.get(
+ order=dashboard_env[3],
+ item=dashboard_env[4]
+ )
+ Checkin.objects.create(position=op)
+ c = checkin_widget(dashboard_env[0])
+ assert '1/1' in c[0]['content']
+
+
+@pytest.fixture
+def checkin_list_env():
+ # permission
+ orga = Organizer.objects.create(name='Dummy', slug='dummy')
+ user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
+ team = Team.objects.create(organizer=orga, can_view_orders=True, can_change_orders=True)
+ team.members.add(user)
+
+ # event
+ event = Event.objects.create(
+ organizer=orga, name='Dummy', slug='dummy',
+ date_from=now(), plugins='pretix.plugins.banktransfer,tests.testdummy'
+ )
+ event.settings.set('ticketoutput_testdummy__enabled', True)
+ event.settings.set('attendee_names_asked', True)
+ event.settings.set('locales', ['en', 'de'])
+ team.limit_events.add(event)
+
+ # item
+ item_ticket = Item.objects.create(event=event, name="Ticket", default_price=23, admission=True)
+ item_mascot = Item.objects.create(event=event, name="Mascot", default_price=10, admission=False)
+
+ # order
+ order_pending = Order.objects.create(
+ code='PENDING', event=event, email='dummy@dummy.test',
+ status=Order.STATUS_PENDING,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=23, payment_provider='banktransfer', locale='en'
+ )
+ order_a1 = Order.objects.create(
+ code='A1', event=event, email='a1dummy@dummy.test',
+ status=Order.STATUS_PAID,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=33, payment_provider='banktransfer', locale='en'
+ )
+ order_a2 = Order.objects.create(
+ code='A2', event=event, email='a2dummy@dummy.test',
+ status=Order.STATUS_PAID,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=23, payment_provider='banktransfer', locale='en'
+ )
+ order_a3 = Order.objects.create(
+ code='A3', event=event, email='a3dummy@dummy.test',
+ status=Order.STATUS_PAID,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=23, payment_provider='banktransfer', locale='en'
+ )
+
+ # order position
+ op_pending_ticket = OrderPosition.objects.create(
+ order=order_pending,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="Pending"
+ )
+ op_a1_ticket = OrderPosition.objects.create(
+ order=order_a1,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="A1"
+ )
+ op_a1_mascot = OrderPosition.objects.create(
+ order=order_a1,
+ item=item_mascot,
+ variation=None,
+ price=Decimal("10")
+ )
+ op_a2_ticket = OrderPosition.objects.create(
+ order=order_a2,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="A2"
+ )
+ op_a3_ticket = OrderPosition.objects.create(
+ order=order_a3,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="a4", # a3 attendee is a4
+ attendee_email="a3company@dummy.test"
+ )
+
+ # checkin
+ Checkin.objects.create(position=op_a1_ticket, datetime=now() + timedelta(minutes=1))
+ Checkin.objects.create(position=op_a3_ticket)
+
+ return event, user, orga, [item_ticket, item_mascot], [order_pending, order_a1, order_a2, order_a3], \
+ [op_pending_ticket, op_a1_ticket, op_a1_mascot, op_a2_ticket, op_a3_ticket]
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("order_key, expected", [
+ ('', ['A1Ticket', 'A1Mascot', 'A2Ticket', 'A3Ticket']),
+ ('-code', ['A3Ticket', 'A2Ticket', 'A1Ticket', 'A1Mascot']),
+ ('code', ['A1Ticket', 'A1Mascot', 'A2Ticket', 'A3Ticket']),
+ ('-email', ['A3Ticket', 'A2Ticket', 'A1Ticket', 'A1Mascot']),
+ ('email', ['A1Ticket', 'A1Mascot', 'A2Ticket', 'A3Ticket']),
+ # ('-status', ['A3Ticket', 'A1Ticket', 'A1Mascot', 'A2Ticket']),
+ # ('status', ['A1Mascot', 'A2Ticket', 'A1Ticket', 'A3Ticket']),
+ # ('-timestamp', ['A1Ticket', 'A3Ticket', 'A1Mascot', 'A2Ticket']), # A1 checkin date > A3 checkin date
+ # ('timestamp', ['A1Mascot', 'A2Ticket', 'A3Ticket', 'A1Ticket']),
+ # ('-name', ['A3Ticket', 'A2Ticket', 'A1Ticket', 'A1Mascot']),
+ # ('name', ['A1Mascot', 'A1Ticket', 'A2Ticket', 'A3Ticket']), # mascot doesn't include attendee name
+ ('-item', ['A1Ticket', 'A2Ticket', 'A3Ticket', 'A1Mascot']),
+ ('item', ['A1Mascot', 'A1Ticket', 'A2Ticket', 'A3Ticket']),
+])
+def test_checkins_list_ordering(client, checkin_list_env, order_key, expected):
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ response = client.get('/control/event/dummy/dummy/checkins/?ordering=' + order_key)
+ qs = response.context['entries']
+ item_keys = [q.order.code + str(q.item.name) for q in qs]
+ assert item_keys == expected
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("query, expected", [
+ ('status=&item=&user=', ['A1Ticket', 'A1Mascot', 'A2Ticket', 'A3Ticket']),
+ ('status=1&item=&user=', ['A1Ticket', 'A3Ticket']),
+ ('status=0&item=&user=', ['A1Mascot', 'A2Ticket']),
+ ('status=&item=&user=a3dummy', ['A3Ticket']), # match order email
+ ('status=&item=&user=a3dummy', ['A3Ticket']), # match order email,
+ ('status=&item=&user=a4', ['A3Ticket']), # match attendee name
+ ('status=&item=&user=a3company', ['A3Ticket']), # match attendee email
+ ('status=1&item=&user=a3company', ['A3Ticket']),
+])
+def test_checkins_list_filter(client, checkin_list_env, query, expected):
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ response = client.get('/control/event/dummy/dummy/checkins/?' + query)
+ qs = response.context['entries']
+ item_keys = [q.order.code + str(q.item.name) for q in qs]
+ print([str(item.name) + '-' + str(item.id) for item in Item.objects.all()])
+ assert item_keys == expected
+
+
+@pytest.mark.django_db
+def test_checkins_item_filter(client, checkin_list_env):
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ for item in checkin_list_env[3]:
+ response = client.get('/control/event/dummy/dummy/checkins/?item=' + str(item.id))
+ assert all(i.item.id == item.id for i in response.context['entries'])
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("query, expected", [
+ ('status=&item=&user=&ordering=', ['A1Ticket', 'A1Mascot', 'A2Ticket', 'A3Ticket']),
+ ('status=1&item=&user=&ordering=timestamp', ['A3Ticket', 'A1Ticket']),
+ # ('status=0&item=&user=&ordering=-name', ['A2Ticket', 'A1Mascot']),
+ # ('status=&item=Ticket&user=&ordering=checkins__datetime', ['A2Ticket', 'A3Ticket', 'A1Ticket']),
+])
+def test_checkins_list_mixed(client, checkin_list_env, query, expected):
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ response = client.get('/control/event/dummy/dummy/checkins/?' + query)
+ qs = response.context['entries']
+ item_keys = [q.order.code + str(q.item.name) for q in qs]
+ assert item_keys == expected
+
+
+@pytest.fixture
+def checkin_list_with_addon_env():
+ # permission
+ orga = Organizer.objects.create(name='Dummy', slug='dummy')
+ user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
+ team = Team.objects.create(organizer=orga, can_view_orders=True, can_change_orders=True)
+ team.members.add(user)
+
+ # event
+ event = Event.objects.create(
+ organizer=orga, name='Dummy', slug='dummy',
+ date_from=now(), plugins='pretix.plugins.banktransfer,tests.testdummy'
+ )
+ event.settings.set('ticketoutput_testdummy__enabled', True)
+ event.settings.set('attendee_names_asked', True)
+ event.settings.set('locales', ['en', 'de'])
+ team.limit_events.add(event)
+
+ # item
+ cat_adm = ItemCategory.objects.create(event=event, name="Admission")
+ cat_workshop = ItemCategory.objects.create(event=event, name="Admission", is_addon=True)
+ item_ticket = Item.objects.create(event=event, name="Ticket", default_price=23, admission=True, category=cat_adm)
+ item_workshop = Item.objects.create(event=event, name="Workshop", default_price=10, admission=False,
+ category=cat_workshop)
+ ItemAddOn.objects.create(base_item=item_ticket, addon_category=cat_workshop, min_count=0, max_count=2)
+
+ # order
+ order_pending = Order.objects.create(
+ code='PENDING', event=event, email='dummy@dummy.test',
+ status=Order.STATUS_PENDING,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=23, payment_provider='banktransfer', locale='en'
+ )
+ order_a1 = Order.objects.create(
+ code='A1', event=event, email='a1dummy@dummy.test',
+ status=Order.STATUS_PAID,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=33, payment_provider='banktransfer', locale='en'
+ )
+ order_a2 = Order.objects.create(
+ code='A2', event=event, email='a2dummy@dummy.test',
+ status=Order.STATUS_PAID,
+ datetime=now(), expires=now() + timedelta(days=10),
+ total=23, payment_provider='banktransfer', locale='en'
+ )
+
+ # order position
+ op_pending_ticket = OrderPosition.objects.create(
+ order=order_pending,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="Pending"
+ )
+ op_a1_ticket = OrderPosition.objects.create(
+ order=order_a1,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="A1"
+ )
+ op_a1_workshop = OrderPosition.objects.create(
+ order=order_a1,
+ item=item_workshop,
+ variation=None,
+ price=Decimal("10"),
+ addon_to=op_a1_ticket
+ )
+ op_a2_ticket = OrderPosition.objects.create(
+ order=order_a2,
+ item=item_ticket,
+ variation=None,
+ price=Decimal("23"),
+ attendee_name="A2"
+ )
+
+ # checkin
+ Checkin.objects.create(position=op_a1_ticket, datetime=now() + timedelta(minutes=1))
+
+ return event, user, orga, [item_ticket, item_workshop], [order_pending, order_a1, order_a2], \
+ [op_pending_ticket, op_a1_ticket, op_a1_workshop, op_a2_ticket]
+
+
+@pytest.mark.django_db
+def test_checkins_attendee_name_from_addon_available(client, checkin_list_with_addon_env):
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ response = client.get('/control/event/dummy/dummy/checkins/')
+ qs = response.context['entries']
+ item_keys = [q.order.code + str(q.item.name) +
+ (str(q.addon_to.attendee_name) if q.addon_to is not None else str(q.attendee_name)) for q in qs]
+ assert item_keys == ['A1TicketA1', 'A1WorkshopA1', 'A2TicketA2'] # A1Workshop comes from addon_to position
+
+
+@pytest.mark.django_db
+def test_checkins_with_noadm_option(client, checkin_list_with_addon_env):
+ checkin_list_with_addon_env[0].settings.ticket_download_nonadm = False
+ checkin_list_with_addon_env[0].save()
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ response = client.get('/control/event/dummy/dummy/checkins/')
+ qs = response.context['entries']
+ item_keys = [q.order.code + str(q.item.name) +
+ (str(q.addon_to.attendee_name) if q.addon_to is not None else str(q.attendee_name)) for q in qs]
+ assert item_keys == ['A1TicketA1', 'A2TicketA2']
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
index 0f70540ec2..32fd32f20d 100644
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -201,6 +201,7 @@ event_permission_urls = [
("can_change_vouchers", "vouchers/1234/delete", 404),
("can_view_orders", "waitinglist/", 200),
("can_change_orders", "waitinglist/auto_assign", 405),
+ ("can_view_orders", "checkins/", 200),
]