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" %} +
+ {% csrf_token %} +
+ + + + + + + + + + + + + {% for e in entries %} + {% with e.checkins.first as checkin %} + + + + + + + + + {% endwith %} + {% endfor %} + +
{% trans "Order code" %} + {% trans "Item" %} + {% trans "Email" %} + {% trans "Name" %} + {% trans "Status" %} + {% trans "Timestamp" %} +
+ {{ e.order.code }} + {{ e.item.name }}{{ e.order.email }} + {% if e.addon_to %} + {{ e.addon_to.attendee_name }} + {% elif e.attendee_name %} + {{ e.attendee_name }} + {% endif %} + + {% if not checkin %} + {% trans "Not checked in" %} + {% else %} + {% trans "Checked in" %} + {% endif %} + + {% if checkin %} + {{ checkin.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% endif %} +
+
+
+ {% 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), ]