From 8114b47c8cfdac2a3fc6bd5ac5086d50bd5ac654 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 13 Jan 2023 13:48:45 +0100 Subject: [PATCH] API: Support for date ranges in exports --- src/pretix/api/serializers/exporters.py | 43 +++++++++++++++++++++++++ src/pretix/base/timeframes.py | 18 +++++++++++ src/tests/api/test_exporters.py | 35 ++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/src/pretix/api/serializers/exporters.py b/src/pretix/api/serializers/exporters.py index 81287a6df4..f1fe60434a 100644 --- a/src/pretix/api/serializers/exporters.py +++ b/src/pretix/api/serializers/exporters.py @@ -22,8 +22,10 @@ from django import forms from django.http import QueryDict from rest_framework import serializers +from rest_framework.exceptions import ValidationError from pretix.base.exporter import OrganizerLevelExportMixin +from pretix.base.timeframes import DateFrameField, SerializerDateFrameField class FormFieldWrapperField(serializers.Field): @@ -142,6 +144,12 @@ class JobRunSerializer(serializers.Serializer): allow_null=not v.required, validators=v.validators, ) + elif isinstance(v, DateFrameField): + self.fields[k] = SerializerDateFrameField( + required=v.required, + allow_null=not v.required, + validators=v.validators, + ) else: self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required) @@ -151,5 +159,40 @@ class JobRunSerializer(serializers.Serializer): for k, v in self.fields.items(): if isinstance(v, serializers.ManyRelatedField) and k not in data: data[k] = [] + + for fk in self.fields.keys(): + # Backwards compatibility for exports that used to take e.g. (date_from, date_to) or (event_date_from, event_date_to) + # and now only take date_range. + if fk.endswith("_range") and isinstance(self.fields[fk], SerializerDateFrameField) and fk not in data: + if fk.replace("_range", "_from") in data: + d_from = data.pop(fk.replace("_range", "_from")) + if d_from: + d_from = serializers.DateField().to_internal_value(d_from) + else: + d_from = None + if fk.replace("_range", "_to") in data: + d_to = data.pop(fk.replace("_range", "_to")) + if d_to: + d_to = serializers.DateField().to_internal_value(d_to) + else: + d_to = None + data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}' + data = super().to_internal_value(data) return data + + def is_valid(self, raise_exception=False): + super().is_valid(raise_exception=raise_exception) + + fields_keys = set(self.fields.keys()) + input_keys = set(self.initial_data.keys()) + + additional_fields = input_keys - fields_keys + + if bool(additional_fields): + self._errors['fields'] = ['Additional fields not allowed: {}.'.format(list(additional_fields))] + + if self._errors and raise_exception: + raise ValidationError(self.errors) + + return not bool(self._errors) diff --git a/src/pretix/base/timeframes.py b/src/pretix/base/timeframes.py index 4efd19bf55..8727d15cb5 100644 --- a/src/pretix/base/timeframes.py +++ b/src/pretix/base/timeframes.py @@ -24,11 +24,13 @@ from datetime import date, datetime, time, timedelta from itertools import groupby from typing import Optional, Tuple +import pytz from django import forms from django.core.exceptions import ValidationError from django.utils.formats import date_format from django.utils.timezone import make_aware, now from django.utils.translation import gettext_lazy, pgettext_lazy +from rest_framework import serializers from pretix.helpers.daterange import daterange @@ -382,6 +384,22 @@ class DateFrameField(forms.MultiValueField): return super().clean(value) +class SerializerDateFrameField(serializers.CharField): + + def to_internal_value(self, data): + if data is None: + return None + try: + resolve_timeframe_to_dates_inclusive(now(), data, pytz.UTC) + except: + raise ValidationError("Invalid date frame") + + def to_representation(self, value): + if value is None: + return None + return value + + def resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]: """ Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of dates diff --git a/src/tests/api/test_exporters.py b/src/tests/api/test_exporters.py index f42ed2eda2..4463d81242 100644 --- a/src/tests/api/test_exporters.py +++ b/src/tests/api/test_exporters.py @@ -162,6 +162,7 @@ def test_org_validate_events(token_client, organizer, team, event): def test_run_success(token_client, organizer, team, event): resp = token_client.post('/api/v1/organizers/{}/events/{}/exporters/orderlist/run/'.format(organizer.slug, event.slug), data={ '_format': 'xlsx', + 'date_range': 'year_this' }, format='json') assert resp.status_code == 202 assert "download" in resp.data @@ -170,6 +171,40 @@ def test_run_success(token_client, organizer, team, event): assert resp["Content-Type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +@pytest.mark.django_db +def test_run_success_old_date_frame(token_client, organizer, team, event): + resp = token_client.post('/api/v1/organizers/{}/events/{}/exporters/orderlist/run/'.format(organizer.slug, event.slug), data={ + '_format': 'xlsx', + 'date_from': '2020-01-01', + 'date_to': '2023-12-31' + }, format='json') + assert resp.status_code == 202 + assert "download" in resp.data + resp = token_client.get("/" + resp.data["download"].split("/", 3)[3]) + assert resp.status_code == 200 + assert resp["Content-Type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + +@pytest.mark.django_db +def test_run_date_frame_validation(token_client, organizer, team, event): + resp = token_client.post('/api/v1/organizers/{}/events/{}/exporters/orderlist/run/'.format(organizer.slug, event.slug), data={ + '_format': 'xlsx', + 'date_range': 'invalid' + }, format='json') + assert resp.status_code == 400 + assert resp.data == {"date_range": ["Invalid date frame"]} + + +@pytest.mark.django_db +def test_run_additional_fields_forbidden(token_client, organizer, team, event): + resp = token_client.post('/api/v1/organizers/{}/events/{}/exporters/orderlist/run/'.format(organizer.slug, event.slug), data={ + '_format': 'xlsx', + 'foobar': 'invalid' + }, format='json') + assert resp.status_code == 400 + assert resp.data == {"fields": ["Additional fields not allowed: ['foobar']."]} + + @pytest.mark.django_db def test_download_nonexisting(token_client, organizer, team, event): resp = token_client.get('/api/v1/organizers/{}/events/{}/exporters/orderlist/download/{}/{}/'.format(