diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 08fb11c496..ad80a41c87 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -32,6 +32,10 @@ checkin_count integer Number of check This resource has been added. +.. versionchanged:: 1.11 + + The ``positions`` endpoints have been added. + Endpoints --------- @@ -236,3 +240,163 @@ Endpoints :statuscode 204: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. + + +Order position endpoints +------------------------ + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/ + + Returns a list of all order positions within a given event. The result is the same as + the :ref:`order-position-resource`, with one important difference: the ``checkins`` value will only include + check-ins for the selected list. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 23442, + "order": "ABC12", + "positionid": 1, + "item": 1345, + "variation": null, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_rule": null, + "tax_value": "0.00", + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": null, + "subevent": null, + "checkins": [ + { + "list": 1, + "datetime": "2017-12-25T12:45:23Z" + } + ], + "answers": [ + { + "question": 12, + "answer": "Foo", + "options": [] + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" + } + ] + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``, + ``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default: + ``attendee_name,positionid`` + :query string order: Only return positions of the order with the given order code + :query integer item: Only return positions with the purchased item matching the given ID. + :query integer variation: Only return positions with the purchased item variation matching the given ID. + :query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on + products positions are shown if they refer to an attendee with the given name. + :query string secret: Only return positions with the given ticket secret. + :query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been + checked in already on this list. + :query integer subevent: Only return positions of the sub-event with the given ID + :query integer addon_to: Only return positions that are add-ons to the position with the given ID. + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param list: The ID of the check-in list to look for + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested check-in list does not exist. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id) + + Returns information on one order position, identified by its internal ID. + The result format is the same as the :ref:`order-position-resource`, with one important difference: the + ``checkins`` value will only include check-ins for the selected list. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 23442, + "order": "ABC12", + "positionid": 1, + "item": 1345, + "variation": null, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_rule": null, + "tax_value": "0.00", + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": null, + "subevent": null, + "checkins": [ + { + "list": 1, + "datetime": "2017-12-25T12:45:23Z" + } + ], + "answers": [ + { + "question": 12, + "answer": "Foo", + "options": [] + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param list: The ID of the check-in list to look for + :param id: The ``id`` field of the order position to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order position or check-in list does not exist. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index b5ff94ae9d..215bdf1b84 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -86,6 +86,8 @@ downloads list of objects List of ticket First write operations (``…/mark_paid/``, ``…/mark_pending/``, ``…/mark_canceled/``, ``…/mark_expired/``) have been added. The attribute ``invoice_address.internal_reference`` has been added. +.. _order-position-resource: + Order position resource ----------------------- @@ -110,6 +112,7 @@ secret string Secret code pri addon_to integer Internal ID of the position this position is an add-on for (or ``null``) subevent integer ID of the date inside an event series this position belongs to (or ``null``). checkins list of objects List of check-ins with this ticket +├ list integer Internal ID of the check-in list └ datetime datetime Time of check-in downloads list of objects List of ticket download options ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) @@ -124,6 +127,10 @@ answers list of objects Answers to user The attribute ``tax_rule`` has been added. +.. versionchanged:: 1.11 + + The attribute ``checkins.list`` has been added. + Order endpoints --------------- @@ -198,6 +205,7 @@ Order endpoints "subevent": null, "checkins": [ { + "list": 44, "datetime": "2017-12-25T12:45:23Z" } ], @@ -304,6 +312,7 @@ Order endpoints "subevent": null, "checkins": [ { + "list": 44, "datetime": "2017-12-25T12:45:23Z" } ], @@ -622,6 +631,7 @@ Order position endpoints "subevent": null, "checkins": [ { + "list": 44, "datetime": "2017-12-25T12:45:23Z" } ], @@ -701,6 +711,7 @@ Order position endpoints "subevent": null, "checkins": [ { + "list": 44, "datetime": "2017-12-25T12:45:23Z" } ], diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 1206382a3b..d1ad465326 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -38,7 +38,7 @@ class AnswerSerializer(I18nAwareModelSerializer): class CheckinSerializer(I18nAwareModelSerializer): class Meta: model = Checkin - fields = ('datetime',) + fields = ('datetime', 'list') class OrderDownloadsField(serializers.Field): diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 1a8cbe89ea..0a8e3b2974 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -26,6 +26,9 @@ event_router.register(r'taxrules', event.TaxRuleViewSet) event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) event_router.register(r'checkinlists', checkin.CheckinListViewSet) +checkinlist_router = routers.DefaultRouter() +checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet) + # Force import of all plugins to give them a chance to register URLs with the router for app in apps.get_app_configs(): if hasattr(app, 'PretixPluginMeta'): @@ -36,4 +39,6 @@ urlpatterns = [ url(r'^', include(router.urls)), url(r'^organizers/(?P[^/]+)/', include(orga_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/', include(event_router.urls)), + url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/checkinlists/(?P[^/]+)/', + include(checkinlist_router.urls)), ] diff --git a/src/pretix/api/views/__init__.py b/src/pretix/api/views/__init__.py index e69de29bb2..7ac4df2609 100644 --- a/src/pretix/api/views/__init__.py +++ b/src/pretix/api/views/__init__.py @@ -0,0 +1,23 @@ +from rest_framework.filters import OrderingFilter + + +class RichOrderingFilter(OrderingFilter): + + def filter_queryset(self, request, queryset, view): + ordering = self.get_ordering(request, queryset, view) + + if ordering: + if hasattr(view, 'ordering_custom'): + newo = [] + for ordering_part in ordering: + ob = view.ordering_custom.get(ordering_part) + if ob: + ob = dict(ob) + newo.append(ob.pop('_order')) + queryset = queryset.annotate(**ob) + else: + newo.append(ordering_part) + ordering = newo + return queryset.order_by(*ordering) + + return queryset diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 1f5f7da594..5538bcb0a5 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -1,9 +1,17 @@ +import django_filters +from django.db.models import F, Max, OuterRef, Prefetch, Q, Subquery +from django.db.models.functions import Coalesce +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import viewsets from pretix.api.serializers.checkin import CheckinListSerializer -from pretix.base.models import CheckinList +from pretix.api.serializers.order import OrderPositionSerializer +from pretix.api.views import RichOrderingFilter +from pretix.base.models import Checkin, CheckinList, Order, OrderPosition from pretix.base.models.organizer import TeamAPIToken +from pretix.helpers.database import FixedOrderBy class CheckinListFilter(FilterSet): @@ -57,3 +65,79 @@ class CheckinListViewSet(viewsets.ModelViewSet): api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), ) super().perform_destroy(instance) + + +class OrderPositionFilter(FilterSet): + order = django_filters.CharFilter(name='order', lookup_expr='code') + has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs') + attendee_name = django_filters.CharFilter(method='attendee_name_qs') + + def has_checkin_qs(self, queryset, name, value): + return queryset.filter(last_checked_in__isnull=not value) + + def attendee_name_qs(self, queryset, name, value): + return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value)) + + class Meta: + model = OrderPosition + fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'has_checkin', 'addon_to', 'subevent'] + + +class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = OrderPositionSerializer + queryset = OrderPosition.objects.none() + filter_backends = (DjangoFilterBackend, RichOrderingFilter) + ordering = ('attendee_name', 'positionid') + ordering_fields = ( + 'order__code', 'order__datetime', 'positionid', 'attendee_name', + 'last_checked_in', 'order__email', + ) + ordering_custom = { + 'attendee_name': { + '_order': F('display_name').asc(nulls_first=True), + 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name') + }, + '-attendee_name': { + '_order': F('display_name').desc(nulls_last=True), + 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name') + }, + 'last_checked_in': { + '_order': FixedOrderBy(F('last_checked_in'), nulls_first=True), + }, + '-last_checked_in': { + '_order': FixedOrderBy(F('last_checked_in'), nulls_last=True, descending=True), + }, + } + + filter_class = OrderPositionFilter + permission = 'can_view_orders' + + @cached_property + def checkinlist(self): + return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list")) + + def get_queryset(self): + cqs = Checkin.objects.filter( + position_id=OuterRef('pk'), + list_id=self.checkinlist.pk + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') + + qs = OrderPosition.objects.filter( + order__event=self.request.event, + order__status=Order.STATUS_PAID, + subevent=self.checkinlist.subevent + ).annotate( + last_checked_in=Subquery(cqs) + ).prefetch_related( + Prefetch( + lookup='checkins', + queryset=Checkin.objects.filter(list_id=self.checkinlist.pk) + ) + ).select_related('item', 'variation', 'order', 'addon_to') + + if not self.checkinlist.all_products: + qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True)) + + return qs diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index c4220db8a3..976ecd06bb 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -1,6 +1,15 @@ -import pytest +import datetime +import time +from decimal import Decimal +from unittest import mock -from pretix.base.models import CheckinList +import pytest +from django_countries.fields import Country +from pytz import UTC + +from pretix.base.models import ( + CheckinList, InvoiceAddress, Order, OrderPosition, +) @pytest.fixture @@ -18,6 +27,84 @@ def other_item(event): return event.items.create(name="Budget Ticket", default_price=23) +@pytest.fixture +def order(event, item, other_item, taxrule): + testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PAID, secret="k24fiuwvu8kxz3y1", + datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC), + expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), + total=46, payment_provider='banktransfer', locale='en' + ) + InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ')) + OrderPosition.objects.create( + order=o, + positionid=1, + item=item, + variation=None, + price=Decimal("23"), + attendee_name="Peter", + secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + ) + OrderPosition.objects.create( + order=o, + positionid=2, + item=other_item, + variation=None, + price=Decimal("23"), + attendee_name="Michael", + secret="sf4HZG73fU6kwddgjg2QOusFbYZwVKpK" + ) + return o + + +TEST_ORDERPOSITION1_RES = { + "id": 1, + "order": "FOO", + "positionid": 1, + "item": 1, + "variation": None, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": None, + "voucher": None, + "tax_rate": "0.00", + "tax_value": "0.00", + "tax_rule": None, + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": None, + "checkins": [], + "downloads": [], + "answers": [], + "subevent": None +} + + +TEST_ORDERPOSITION2_RES = { + "id": 2, + "order": "FOO", + "positionid": 2, + "item": 1, + "variation": None, + "price": "23.00", + "attendee_name": "Michael", + "attendee_email": None, + "voucher": None, + "tax_rate": "0.00", + "tax_value": "0.00", + "tax_rule": None, + "secret": "sf4HZG73fU6kwddgjg2QOusFbYZwVKpK", + "addon_to": None, + "checkins": [], + "downloads": [], + "answers": [], + "subevent": None +} + TEST_LIST_RES = { "name": "Default", "all_products": False, @@ -35,6 +122,12 @@ def clist(event, item): return c +@pytest.fixture +def clist_all(event, item): + c = event.checkin_lists.create(name="Default", all_products=True) + return c + + @pytest.mark.django_db def test_list_list(token_client, organizer, event, clist, item, subevent): res = dict(TEST_LIST_RES) @@ -166,3 +259,130 @@ def test_list_update(token_client, organizer, event, clist): assert resp.status_code == 200 cl = CheckinList.objects.get(pk=resp.data['id']) assert cl.name == "VIP" + + +@pytest.mark.django_db +def test_list_all_items_positions(token_client, organizer, event, clist, clist_all, item, other_item, order): + p1 = dict(TEST_ORDERPOSITION1_RES) + p1["id"] = order.positions.first().pk + p1["item"] = item.pk + p2 = dict(TEST_ORDERPOSITION2_RES) + p2["id"] = order.positions.last().pk + p2["item"] = other_item.pk + + # All items + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p1, p2] == resp.data['results'] + + # Check-ins on other list ignored + order.positions.first().checkins.create(list=clist) + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p1, p2] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?has_checkin=1'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + # Only checked in + c = order.positions.first().checkins.create(list=clist_all) + p1['checkins'] = [ + { + 'list': clist_all.pk, + 'datetime': c.datetime.isoformat().replace('+00:00', 'Z') + } + ] + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?has_checkin=1'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p1] == resp.data['results'] + + # Only not checked in + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?has_checkin=0'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p2] == resp.data['results'] + + # Order by checkin + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=-last_checked_in'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p1, p2] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=last_checked_in'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p2, p1] == resp.data['results'] + + # Order by checkin date + time.sleep(1) + c = order.positions.last().checkins.create(list=clist_all) + p2['checkins'] = [ + { + 'list': clist_all.pk, + 'datetime': c.datetime.isoformat().replace('+00:00', 'Z') + } + ] + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=-last_checked_in'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p2, p1] == resp.data['results'] + + # Order by attendee_name + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=-attendee_name'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p1, p2] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=attendee_name'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p2, p1] == resp.data['results'] + + # Paid only + order.status = Order.STATUS_PENDING + order.save() + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_list_limited_items_positions(token_client, organizer, event, clist, item, order): + p1 = dict(TEST_ORDERPOSITION1_RES) + p1["id"] = order.positions.first().pk + p1["item"] = item.pk + + # All items + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format( + organizer.slug, event.slug, clist.pk + )) + assert resp.status_code == 200 + assert [p1] == resp.data['results'] + + +@pytest.mark.django_db +def test_list_limited_items_position_detail(token_client, organizer, event, clist, item, order): + p1 = dict(TEST_ORDERPOSITION1_RES) + p1["id"] = order.positions.first().pk + p1["item"] = item.pk + + # All items + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/'.format( + organizer.slug, event.slug, clist.pk, order.positions.first().pk + )) + assert resp.status_code == 200 + assert p1 == resp.data