diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index 3b668c98d4..e8d897e1b4 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -109,6 +109,41 @@ respective page. The field ``results`` contains a list of objects representing the first results. For most objects, every page contains 50 results. +Conditional fetching +-------------------- + +If you pull object lists from pretix' APIs regularly, we ask you to implement conditional fetching +to avoid unnecessary data traffic. This is not supported on all resources and we currently implement +two different mechanisms for different resources, which is necessary because we can only obtain best +efficiency for resources that do not support deletion operations. + +Object-level conditional fetching +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The :ref:`rest-orders` resource list contains an HTTP header called ``X-Page-Generated`` containing the +current time on the server in ISO 8601 format. On your next request, you can pass this header +(as is, without any modifications necessary) as the ``modified_since`` query parameter and you will receive +a list containing only objects that have changed in the time since your last request. + +List-level conditional fetching +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If modificiation checks are not possible with this granularity, you can instead check for the full list. +In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the +last modification to any item of that resource. You can then pass this date back in your next request in the +``If-Modified-Since`` header. If the any object has changed in the meantime, you will receive back a full list +(if something it missing, this means the object has been deleted). If nothing happend, we'll send back a +``304 Not Modified`` return code. + +This is currently implemented on the following resources: + +* :ref:`rest-categories` +* :ref:`rest-items` +* :ref:`rest-questions` +* :ref:`rest-quotas` +* :ref:`rest-subevents` +* :ref:`rest-taxrules` + Errors ------ diff --git a/doc/api/resources/categories.rst b/doc/api/resources/categories.rst index 5645e3c176..49f1cdf46c 100644 --- a/doc/api/resources/categories.rst +++ b/doc/api/resources/categories.rst @@ -1,3 +1,5 @@ +.. _`rest-categories`: + Item categories =============== diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 87a7af3c31..91aca9be08 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -1,3 +1,5 @@ +.. _rest-items: + Items ===== diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 99ddf8a0b6..c5a6443a73 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1,5 +1,8 @@ .. spelling:: checkins + +.. _rest-orders: + Orders ====== diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index 4bac9d0601..ff2ecfc94f 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -1,5 +1,7 @@ .. spelling:: checkin +.. _rest-questions: + Questions ========= diff --git a/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst index 30a2d5bb77..75e4898542 100644 --- a/doc/api/resources/quotas.rst +++ b/doc/api/resources/quotas.rst @@ -1,3 +1,5 @@ +.. _rest-quotas: + Quotas ====== diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst index e14263c3cd..80fa4717da 100644 --- a/doc/api/resources/subevents.rst +++ b/doc/api/resources/subevents.rst @@ -1,3 +1,5 @@ +.. _rest-subevents: + Event series dates / Sub-events =============================== diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst index 10f958a8a5..c50ddcc964 100644 --- a/doc/api/resources/taxrules.rst +++ b/doc/api/resources/taxrules.rst @@ -1,3 +1,5 @@ +.. _rest-taxrules: + Tax rules ========= diff --git a/src/pretix/api/views/__init__.py b/src/pretix/api/views/__init__.py index 7ac4df2609..6e8573752d 100644 --- a/src/pretix/api/views/__init__.py +++ b/src/pretix/api/views/__init__.py @@ -1,3 +1,8 @@ +from calendar import timegm + +from django.db.models import Max +from django.http import HttpResponse +from django.utils.http import http_date, parse_http_date_safe from rest_framework.filters import OrderingFilter @@ -21,3 +26,34 @@ class RichOrderingFilter(OrderingFilter): return queryset.order_by(*ordering) return queryset + + +class ConditionalListView: + + def list(self, request, **kwargs): + if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE') + if if_modified_since: + if_modified_since = parse_http_date_safe(if_modified_since) + if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE') + if if_unmodified_since: + if_unmodified_since = parse_http_date_safe(if_unmodified_since) + lmd = request.event.logentry_set.filter( + content_type__model=self.queryset.model._meta.model_name, + content_type__app_label=self.queryset.model._meta.app_label, + ).aggregate( + m=Max('datetime') + )['m'] + if lmd: + lmd_ts = timegm(lmd.utctimetuple()) + print(lmd_ts, if_modified_since) + + if if_unmodified_since and lmd and lmd_ts > if_unmodified_since: + return HttpResponse(status=412) + + if if_modified_since and lmd and lmd_ts <= if_modified_since: + return HttpResponse(status=304) + + resp = super().list(request, **kwargs) + if lmd: + resp['Last-Modified'] = http_date(lmd_ts) + return resp diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 989ae0fc4c..83ffdd3afe 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -9,6 +9,7 @@ from pretix.api.serializers.event import ( CloneEventSerializer, EventSerializer, SubEventSerializer, TaxRuleSerializer, ) +from pretix.api.views import ConditionalListView from pretix.base.models import Event, ItemCategory, TaxRule from pretix.base.models.event import SubEvent from pretix.base.models.organizer import TeamAPIToken @@ -125,7 +126,7 @@ class SubEventFilter(FilterSet): fields = ['active'] -class SubEventViewSet(viewsets.ReadOnlyModelViewSet): +class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet): serializer_class = SubEventSerializer queryset = ItemCategory.objects.none() filter_backends = (DjangoFilterBackend, filters.OrderingFilter) @@ -137,7 +138,7 @@ class SubEventViewSet(viewsets.ReadOnlyModelViewSet): ) -class TaxRuleViewSet(viewsets.ModelViewSet): +class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = TaxRuleSerializer queryset = TaxRule.objects.none() write_permission = 'can_change_event_settings' diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index e941d791a6..3c6a336a28 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -13,6 +13,7 @@ from pretix.api.serializers.item import ( ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer, QuotaSerializer, ) +from pretix.api.views import ConditionalListView from pretix.base.models import ( Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, Quota, @@ -35,7 +36,7 @@ class ItemFilter(FilterSet): fields = ['active', 'category', 'admission', 'tax_rate', 'free_price'] -class ItemViewSet(viewsets.ModelViewSet): +class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = ItemSerializer queryset = Item.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -203,7 +204,7 @@ class ItemCategoryFilter(FilterSet): fields = ['is_addon'] -class ItemCategoryViewSet(viewsets.ModelViewSet): +class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = ItemCategorySerializer queryset = ItemCategory.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -257,7 +258,7 @@ class QuestionFilter(FilterSet): fields = ['ask_during_checkin', 'required', 'identifier'] -class QuestionViewSet(viewsets.ModelViewSet): +class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = QuestionSerializer queryset = Question.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -355,7 +356,7 @@ class QuotaFilter(FilterSet): fields = ['subevent'] -class QuotaViewSet(viewsets.ModelViewSet): +class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = QuotaSerializer queryset = Quota.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter,) diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index f2b6b0d5f5..646d56659c 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -1,3 +1,4 @@ +import time from datetime import datetime, timedelta from decimal import Decimal from unittest import mock @@ -116,6 +117,22 @@ def test_category_list(token_client, organizer, event, team, category): assert resp.status_code == 200 assert [res] == resp.data['results'] + category.log_action('foo') + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/'.format( + organizer.slug, event.slug)) + assert resp.status_code == 200 + lmd = resp['Last-Modified'] + assert lmd + time.sleep(1) + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/'.format( + organizer.slug, event.slug), HTTP_IF_MODIFIED_SINCE=lmd) + assert resp.status_code == 304 + time.sleep(1) + category.log_action('foo') + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/'.format( + organizer.slug, event.slug), HTTP_IF_MODIFIED_SINCE=lmd) + assert resp.status_code == 200 + @pytest.mark.django_db def test_category_detail(token_client, organizer, event, team, category):