diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index ce672f9814..f95eac7fdf 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -49,7 +49,7 @@ invoice_address object Invoice address └ vat_id_validated string ``True``, if the VAT ID has been validated against the EU VAT service and validation was successful. This only happens in rare cases. -position list of objects List of order positions (see below) +positions list of objects List of order positions (see below) fees list of objects List of fees included in the order total (i.e. payment fees) ├ fee_type string Type of fee (currently ``payment``, ``passbook``, @@ -68,6 +68,7 @@ downloads list of objects List of ticket download options. ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) └ url string Download URL +last_modified datetime Last modification of this object ===================================== ========================== ======================================================= @@ -96,6 +97,10 @@ downloads list of objects List of ticket The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate``, ``order.payment_fee_tax_value`` and ``order.payment_fee_tax_rule`` have finally been removed. +.. versionchanged:: 1.16 + + The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added. + .. _order-position-resource: Order position resource @@ -174,6 +179,7 @@ Order endpoints HTTP/1.1 200 OK Vary: Accept Content-Type: application/json + X-Page-Generated: 2017-12-01T10:00:00Z { "count": 1, @@ -188,6 +194,7 @@ Order endpoints "locale": "en", "datetime": "2017-12-01T10:00:00Z", "expires": "2017-12-10T10:00:00Z", + "last_modified": "2017-12-01T10:00:00Z", "payment_date": "2017-12-05", "payment_provider": "banktransfer", "fees": [], @@ -264,8 +271,11 @@ Order endpoints :query string status: Only return orders in the given order status (see above) :query string email: Only return orders created with the given email address :query string locale: Only return orders with the given customer locale + :query datetime modified_since: Only return orders that have changed since the given date :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch + :resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch + differences, this is the value you want to use as ``modified_since`` in your next call. :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. @@ -298,6 +308,7 @@ Order endpoints "locale": "en", "datetime": "2017-12-01T10:00:00Z", "expires": "2017-12-10T10:00:00Z", + "last_modified": "2017-12-01T10:00:00Z", "payment_date": "2017-12-05", "payment_provider": "banktransfer", "fees": [], diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 36b629f474..9d11b3acc6 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1,5 +1,3 @@ -from decimal import Decimal - from rest_framework import serializers from rest_framework.reverse import reverse @@ -123,18 +121,6 @@ class OrderFeeSerializer(I18nAwareModelSerializer): fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule') -class PaymentFeeLegacyField(serializers.Field): - def __init__(self, *args, **kwargs): - self.attr = kwargs.pop('attribute') - super().__init__(*args, **kwargs) - - def to_representation(self, instance: Order): - return str( - sum([getattr(f, self.attr) for f in instance.fees.all() if f.fee_type == OrderFee.FEE_TYPE_PAYMENT], - Decimal('0.00')) - ) - - class OrderSerializer(I18nAwareModelSerializer): invoice_address = InvoiceAddressSerializer() positions = OrderPositionSerializer(many=True) @@ -145,7 +131,7 @@ class OrderSerializer(I18nAwareModelSerializer): model = Order fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', - 'checkin_attention') + 'checkin_attention', 'last_modified') class InlineInvoiceLineSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index e347d01531..09466371c6 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -5,7 +5,7 @@ import pytz from django.db.models import Q from django.db.models.functions import Concat from django.http import FileResponse -from django.utils.timezone import make_aware +from django.utils.timezone import make_aware, now from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import serializers, status, viewsets from rest_framework.decorators import detail_route @@ -38,6 +38,7 @@ class OrderFilter(FilterSet): email = django_filters.CharFilter(name='email', lookup_expr='iexact') code = django_filters.CharFilter(name='code', lookup_expr='iexact') status = django_filters.CharFilter(name='status', lookup_expr='iexact') + modified_since = django_filters.IsoDateTimeFilter(name='last_modified', lookup_expr='gte') class Meta: model = Order @@ -71,6 +72,18 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): return prov raise NotFound('Unknown output provider.') + def list(self, request, **kwargs): + date = serializers.DateTimeField().to_representation(now()) + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, headers={'X-Page-Generated': date}) + @detail_route(url_name='download', url_path='download/(?P[^/]+)') def download(self, request, output, **kwargs): provider = self._get_output_provider(output) diff --git a/src/pretix/base/migrations/0091_auto_20180513_1641.py b/src/pretix/base/migrations/0091_auto_20180513_1641.py new file mode 100644 index 0000000000..7d5a7eb98b --- /dev/null +++ b/src/pretix/base/migrations/0091_auto_20180513_1641.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-05-13 16:41 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0090_auto_20180509_0917'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='last_modified', + field=models.DateTimeField(auto_now=True, db_index=True), + ), + ] diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index d726d1bbb1..a75d52573d 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -168,3 +168,11 @@ class Checkin(models.Model): return "".format( self.position, self.list, self.datetime ) + + def save(self, **kwargs): + self.position.order.touch() + super().save(**kwargs) + + def delete(self, **kwargs): + self.position.order.touch() + super().delete(**kwargs) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 091bc13339..8d3edecd02 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -180,6 +180,9 @@ class Order(LoggedModel): verbose_name=_("Meta information"), null=True, blank=True ) + last_modified = models.DateTimeField( + auto_now=True, db_index=True + ) class Meta: verbose_name = _("Order") @@ -208,12 +211,17 @@ class Order(LoggedModel): def changable(self): return self.status in (Order.STATUS_PAID, Order.STATUS_PENDING) - def save(self, *args, **kwargs): + def save(self, **kwargs): + if 'update_fields' in kwargs and 'last_modified' not in kwargs['update_fields']: + kwargs['update_fields'] = list(kwargs['update_fields']) + ['last_modified'] if not self.code: self.assign_code() if not self.datetime: self.datetime = now() - super().save(*args, **kwargs) + super().save(**kwargs) + + def touch(self): + self.save(update_fields=['last_modified']) @cached_property def tax_total(self): @@ -547,8 +555,15 @@ class QuestionAnswer(models.Model): def save(self, *args, **kwargs): if self.orderposition and self.cartposition: raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.') + if self.orderposition: + self.orderposition.order.touch() super().save(*args, **kwargs) + def delete(self, **kwargs): + if self.orderposition: + self.orderposition.order.touch() + super().delete(**kwargs) + class AbstractPosition(models.Model): """ @@ -751,8 +766,13 @@ class OrderFee(models.Model): def save(self, *args, **kwargs): if self.tax_rate is None: self._calculate_tax() + self.order.touch() return super().save(*args, **kwargs) + def delete(self, **kwargs): + self.order.touch() + super().delete(**kwargs) + class OrderPosition(AbstractPosition): """ @@ -861,6 +881,7 @@ class OrderPosition(AbstractPosition): def save(self, *args, **kwargs): if self.tax_rate is None: self._calculate_tax() + self.order.touch() if self.pk is None: while OrderPosition.objects.filter(secret=self.secret).exists(): self.secret = generate_position_secret() @@ -945,6 +966,11 @@ class InvoiceAddress(models.Model): blank=True ) + def save(self, **kwargs): + if self.order: + self.order.touch() + super().save(**kwargs) + def cachedticket_name(instance, filename: str) -> str: secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5cc8e1dd25..f275dfe56e 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1143,6 +1143,7 @@ class OrderChangeManager: self._recalculate_total_and_payment_fee() self._reissue_invoice() self._clear_tickets_cache() + self.order.touch() self._check_paid_to_free() if self.notify: diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 2583c9ef30..5b79febc3f 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -144,6 +144,7 @@ def test_order_list(token_client, organizer, event, order, item, taxrule, questi res["positions"][0]["id"] = order.positions.first().pk res["positions"][0]["item"] = item.pk res["positions"][0]["answers"][0]["question"] = question.pk + res["last_modified"] = order.last_modified.isoformat().replace('+00:00', 'Z') res["fees"][0]["tax_rule"] = taxrule.pk resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/'.format(organizer.slug, event.slug)) @@ -172,6 +173,19 @@ def test_order_list(token_client, organizer, event, order, item, taxrule, questi resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?locale=de'.format(organizer.slug, event.slug)) assert [] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?modified_since={}'.format( + organizer.slug, event.slug, (order.last_modified - datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z') + )) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?modified_since={}'.format( + organizer.slug, event.slug, order.last_modified.isoformat().replace('+00:00', 'Z') + )) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?modified_since={}'.format( + organizer.slug, event.slug, (order.last_modified + datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z') + )) + assert [] == resp.data['results'] + @pytest.mark.django_db def test_order_detail(token_client, organizer, event, order, item, taxrule, question): @@ -180,6 +194,7 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques res["positions"][0]["item"] = item.pk res["fees"][0]["tax_rule"] = taxrule.pk res["positions"][0]["answers"][0]["question"] = question.pk + res["last_modified"] = order.last_modified.isoformat().replace('+00:00', 'Z') resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/'.format(organizer.slug, event.slug, order.code)) assert resp.status_code == 200