diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst
index 010f07736c..00b2d261c0 100644
--- a/doc/api/fundamentals.rst
+++ b/doc/api/fundamentals.rst
@@ -192,6 +192,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
specifiers in requests
(see below).
+Date range *either* two dates separated ``2022-03-18/2022-03-23``, ``2022-03-18/``,
+ by ``/`` *or* the name of a ``/2022-03-23``, ``week_this``, ``week_next``,
+ defined range. ``month_this``
===================== ============================ ===================================
Query parameters
diff --git a/src/pretix/base/exporters/dekodi.py b/src/pretix/base/exporters/dekodi.py
index a75cf78466..4afe0d905a 100644
--- a/src/pretix/base/exporters/dekodi.py
+++ b/src/pretix/base/exporters/dekodi.py
@@ -23,10 +23,9 @@ import json
from collections import OrderedDict
from decimal import Decimal
-import dateutil
-from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver
+from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
from pretix.base.i18n import language
@@ -34,6 +33,7 @@ from pretix.base.models import Invoice, OrderPayment
from ..exporter import BaseExporter
from ..signals import register_data_exporters
+from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class DekodiNREIExporter(BaseExporter):
@@ -115,7 +115,7 @@ class DekodiNREIExporter(BaseExporter):
'PTNo14': p.info_data.get('reference') or '',
'PTNo15': p.full_id or '',
})
- elif p.provider.startswith('stripe'):
+ elif p.provider and p.provider.startswith('stripe'):
src = p.info_data.get("source", p.info_data)
payments.append({
'PTID': '81',
@@ -194,17 +194,12 @@ class DekodiNREIExporter(BaseExporter):
def render(self, form_data):
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
- if form_data.get('date_from'):
- date_value = form_data.get('date_from')
- if isinstance(date_value, str):
- date_value = dateutil.parser.parse(date_value).date()
- qs = qs.filter(date__gte=date_value)
-
- if form_data.get('date_to'):
- date_value = form_data.get('date_to')
- if isinstance(date_value, str):
- date_value = dateutil.parser.parse(date_value).date()
- qs = qs.filter(date__lte=date_value)
+ if form_data.get('date_range'):
+ d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
+ if d_start:
+ qs = qs.filter(date__gte=d_start)
+ if d_end:
+ qs = qs.filter(date__lte=d_end)
jo = {
'Format': 'NREI',
@@ -220,22 +215,14 @@ class DekodiNREIExporter(BaseExporter):
def export_form_fields(self):
return OrderedDict(
[
- ('date_from',
- forms.DateField(
- label=gettext_lazy('Start date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
+ ('date_range',
+ DateFrameField(
+ label=gettext_lazy('Date range'),
+ include_future_frames=False,
required=False,
- help_text=gettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
+ help_text=gettext_lazy('Only include invoices issued in this time frame. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
- ('date_to',
- forms.DateField(
- label=gettext_lazy('End date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
- help_text=gettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
- 'does not always correspond to the order or payment date.')
- )),
]
)
diff --git a/src/pretix/base/exporters/invoices.py b/src/pretix/base/exporters/invoices.py
index 31f0eb149d..8db24df5f3 100644
--- a/src/pretix/base/exporters/invoices.py
+++ b/src/pretix/base/exporters/invoices.py
@@ -38,12 +38,12 @@ from collections import OrderedDict
from decimal import Decimal
from zipfile import ZipFile
-import dateutil.parser
from django import forms
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
+from django.utils.timezone import now
from django.utils.translation import (
gettext, gettext_lazy as _, pgettext, pgettext_lazy,
)
@@ -59,6 +59,7 @@ from ..services.invoices import invoice_pdf_task
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
+from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class InvoiceExporterMixin:
@@ -68,22 +69,14 @@ class InvoiceExporterMixin:
def invoice_exporter_form_fields(self):
return OrderedDict(
[
- ('date_from',
- forms.DateField(
- label=_('Start date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
+ ('date_range',
+ DateFrameField(
+ label=_('Date range'),
+ include_future_frames=False,
required=False,
- help_text=_('Only include invoices issued on or after this date. Note that the invoice date does '
+ help_text=_('Only include invoices issued in this time frame. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
- ('date_to',
- forms.DateField(
- label=_('End date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
- help_text=_('Only include invoices issued on or before this date. Note that the invoice date '
- 'does not always correspond to the order or payment date.')
- )),
('payment_provider',
forms.ChoiceField(
label=_('Payment provider'),
@@ -115,16 +108,12 @@ class InvoiceExporterMixin:
)
)
qs = qs.filter(has_payment_with_provider=1)
- if form_data.get('date_from'):
- date_value = form_data.get('date_from')
- if isinstance(date_value, str):
- date_value = dateutil.parser.parse(date_value).date()
- qs = qs.filter(date__gte=date_value)
- if form_data.get('date_to'):
- date_value = form_data.get('date_to')
- if isinstance(date_value, str):
- date_value = dateutil.parser.parse(date_value).date()
- qs = qs.filter(date__lte=date_value)
+ if form_data.get('date_range'):
+ d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
+ if d_start:
+ qs = qs.filter(date__gte=d_start)
+ if d_end:
+ qs = qs.filter(date__lte=d_end)
return qs
diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py
index 93e11106d6..0a4fa1c5c7 100644
--- a/src/pretix/base/exporters/orderlist.py
+++ b/src/pretix/base/exporters/orderlist.py
@@ -33,10 +33,8 @@
# License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict
-from datetime import date, datetime, time
from decimal import Decimal
-import dateutil
import pytz
from django import forms
from django.db.models import (
@@ -46,7 +44,7 @@ from django.db.models import (
from django.db.models.functions import Coalesce
from django.dispatch import receiver
from django.utils.functional import cached_property
-from django.utils.timezone import get_current_timezone, make_aware, now
+from django.utils.timezone import get_current_timezone, now
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
@@ -68,6 +66,10 @@ from ..exporter import (
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
+from ..timeframes import (
+ DateFrameField,
+ resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
+)
class OrderListExporter(MultiSheetListExporter):
@@ -112,41 +114,25 @@ class OrderListExporter(MultiSheetListExporter):
initial=False,
required=False
)),
- ('date_from',
- forms.DateField(
- label=_('Start date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
+ ('date_range',
+ DateFrameField(
+ label=_('Date range'),
+ include_future_frames=False,
required=False,
- help_text=_('Only include orders created on or after this date.')
+ help_text=_('Only include orders created within this date range.')
)),
- ('date_to',
- forms.DateField(
- label=_('End date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
+ ('event_date_range',
+ DateFrameField(
+ label=_('Event date'),
+ include_future_frames=True,
required=False,
- help_text=_('Only include orders created on or before this date.')
- )),
- ('event_date_from',
- forms.DateField(
- label=_('Start event date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
- help_text=_('Only include orders including at least one ticket for a date on or after this date. '
- 'Will also include other dates in case of mixed orders!')
- )),
- ('event_date_to',
- forms.DateField(
- label=_('End event date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
- help_text=_('Only include orders including at least one ticket for a date on or before this date. '
+ help_text=_('Only include orders including at least one ticket for a date in this range. '
'Will also include other dates in case of mixed orders!')
)),
]
d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents:
- del d['event_date_from']
- del d['event_date_to']
+ del d['event_date_range']
return d
def _get_all_payment_methods(self, qs):
@@ -189,45 +175,27 @@ class OrderListExporter(MultiSheetListExporter):
annotations = {}
filters = {}
- if form_data.get('date_from'):
- date_value = form_data.get('date_from')
- if not isinstance(date_value, date):
- date_value = dateutil.parser.parse(date_value).date()
- datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
+ if form_data.get('date_range'):
+ dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
+ if dt_start:
+ filters[f'{rel}datetime__gte'] = dt_start
+ if dt_end:
+ filters[f'{rel}datetime__lt'] = dt_end
- filters[f'{rel}datetime__gte'] = datetime_value
-
- if form_data.get('date_to'):
- date_value = form_data.get('date_to')
- if not isinstance(date_value, date):
- date_value = dateutil.parser.parse(date_value).date()
- datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
-
- filters[f'{rel}datetime__lte'] = datetime_value
-
- if form_data.get('event_date_from'):
- date_value = form_data.get('event_date_from')
- if not isinstance(date_value, date):
- date_value = dateutil.parser.parse(date_value).date()
- datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
-
- annotations['event_date_max'] = Case(
- When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
- default=F(f'{rel}event__date_from'),
- )
- filters['event_date_max__gte'] = datetime_value
-
- if form_data.get('event_date_to'):
- date_value = form_data.get('event_date_to')
- if not isinstance(date_value, date):
- date_value = dateutil.parser.parse(date_value).date()
- datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
-
- annotations['event_date_min'] = Case(
- When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
- default=F(f'{rel}event__date_from'),
- )
- filters['event_date_min__lte'] = datetime_value
+ if form_data.get('event_date_range'):
+ dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['event_date_range'], self.timezone)
+ if dt_start:
+ annotations['event_date_max'] = Case(
+ When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
+ default=F(f'{rel}event__date_from'),
+ )
+ filters['event_date_max__gte'] = dt_start
+ if dt_end:
+ annotations['event_date_min'] = Case(
+ When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
+ default=F(f'{rel}event__date_from'),
+ )
+ filters['event_date_min__lt'] = dt_end
if filters:
return qs.annotate(**annotations).filter(**filters)
@@ -926,17 +894,11 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
@property
def additional_form_fields(self):
d = [
- ('date_from',
- forms.DateField(
- label=_('Start date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
- )),
- ('date_to',
- forms.DateField(
- label=_('End date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
+ ('date_range',
+ DateFrameField(
+ label=_('Date range'),
+ include_future_frames=False,
+ required=False
)),
]
d = OrderedDict(d)
@@ -947,22 +909,12 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
- if form_data.get('date_from'):
- date_value = form_data.get('date_from')
- if isinstance(date_value, str):
- date_value = dateutil.parser.parse(date_value).date()
- qs = qs.filter(
- datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
- )
-
- if form_data.get('date_to'):
- date_value = form_data.get('date_to')
- if isinstance(date_value, str):
- date_value = dateutil.parser.parse(date_value).date()
-
- qs = qs.filter(
- datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
- )
+ if form_data.get('date_range'):
+ dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
+ if dt_start:
+ qs = qs.filter(datetime__gte=dt_start)
+ if dt_end:
+ qs = qs.filter(datetime__lt=dt_end)
headers = [
_('Gift card code'),
@@ -1048,7 +1000,8 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
[
('date', forms.DateTimeField(
label=_('Show value at'),
- initial=now(),
+ required=False,
+ help_text=_('Defaults to the time of report.')
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
@@ -1076,12 +1029,13 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
)
def iterate_list(self, form_data):
+ d = form_data.get('date') or now()
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
- datetime__lte=form_data['date']
+ datetime__lte=d
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = self.organizer.issued_gift_cards.filter(
- issuance__lte=form_data['date']
+ issuance__lte=d
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
@@ -1096,11 +1050,11 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
- qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
+ qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=d))
elif form_data.get('state') == 'expired_value':
- qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
+ qs = qs.exclude(cached_value=0).filter(expires__lt=d)
elif form_data.get('state') == 'expired':
- qs = qs.filter(expires__lt=form_data['date'])
+ qs = qs.filter(expires__lt=d)
headers = [
_('Gift card code'),
diff --git a/src/pretix/base/templates/pretixbase/forms/widgets/dateframe.html b/src/pretix/base/templates/pretixbase/forms/widgets/dateframe.html
new file mode 100644
index 0000000000..bba036033b
--- /dev/null
+++ b/src/pretix/base/templates/pretixbase/forms/widgets/dateframe.html
@@ -0,0 +1,18 @@
+{% load i18n %}
+{% with widget.subwidgets.0 as widget %}
+{% include widget.template_name %}
+{% endwith %}
+
+
+
+ {% with widget.subwidgets.1 as widget %}
+ {% include widget.template_name %}
+ {% endwith %}
+
+
+ {% with widget.subwidgets.2 as widget %}
+ {% include widget.template_name %}
+ {% endwith %}
+
+
+{% spaceless %}{% for widget in widget.subwidgets %}{% endfor %}{% endspaceless %}
diff --git a/src/pretix/base/timeframes.py b/src/pretix/base/timeframes.py
new file mode 100644
index 0000000000..61942ad46d
--- /dev/null
+++ b/src/pretix/base/timeframes.py
@@ -0,0 +1,413 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import calendar
+from datetime import date, datetime, time, timedelta
+from itertools import groupby
+from typing import Optional, Tuple
+
+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 pretix.helpers.daterange import daterange
+
+
+def _quarter_start(ref_d):
+ return ref_d.replace(day=1, month=1 + (ref_d.month - 1) // 3 * 3)
+
+
+def _week_start(ref_d):
+ return ref_d - timedelta(ref_d.weekday())
+
+
+REPORTING_DATE_TIMEFRAMES = (
+ # (identifier, label, start_inclusive, end_inclusive, includes_future, optgroup, describe)
+ (
+ 'days_today',
+ pgettext_lazy('reporting_timeframe', 'Today'),
+ lambda ref_d: ref_d,
+ lambda ref_d, start_d: start_d,
+ False,
+ pgettext_lazy('reporting_timeframe', 'by day'),
+ daterange
+ ),
+ (
+ 'days_yesterday',
+ pgettext_lazy('reporting_timeframe', 'Yesterday'),
+ lambda ref_d: ref_d - timedelta(days=1),
+ lambda ref_d, start_d: start_d,
+ False,
+ pgettext_lazy('reporting_timeframe', 'by day'),
+ daterange
+ ),
+ (
+ 'days_last7',
+ pgettext_lazy('reporting_timeframe', 'Last 7 days'),
+ lambda ref_d: ref_d - timedelta(days=6),
+ lambda ref_d, start_d: start_d + timedelta(days=6),
+ False,
+ pgettext_lazy('reporting_timeframe', 'by day'),
+ daterange
+ ),
+ (
+ 'days_last14',
+ pgettext_lazy('reporting_timeframe', 'Last 14 days'),
+ lambda ref_d: ref_d - timedelta(days=13),
+ lambda ref_d, start_d: start_d + timedelta(days=13),
+ False,
+ pgettext_lazy('reporting_timeframe', 'by day'),
+ daterange
+ ),
+ (
+ 'days_tomorrow',
+ pgettext_lazy('reporting_timeframe', 'Tomorrow'),
+ lambda ref_d: ref_d + timedelta(days=1),
+ lambda ref_d, start_d: start_d,
+ True,
+ pgettext_lazy('reporting_timeframe', 'by day'),
+ daterange
+ ),
+ (
+ 'days_next7',
+ pgettext_lazy('reporting_timeframe', 'Next 7 days'),
+ lambda ref_d: ref_d + timedelta(days=1),
+ lambda ref_d, start_d: start_d + timedelta(days=6),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by day'),
+ daterange
+ ),
+ (
+ 'days_next14',
+ pgettext_lazy('reporting_timeframe', 'Next 14 days'),
+ lambda ref_d: ref_d + timedelta(days=1),
+ lambda ref_d, start_d: start_d + timedelta(days=13),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by day'),
+ daterange
+ ),
+ (
+ 'week_this',
+ pgettext_lazy('reporting_timeframe', 'Current week'),
+ lambda ref_d: _week_start(ref_d),
+ lambda ref_d, start_d: start_d + timedelta(days=6),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by week'),
+ lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
+ ),
+ (
+ 'week_to_date',
+ pgettext_lazy('reporting_timeframe', 'Current week to date'),
+ lambda ref_d: _week_start(ref_d),
+ lambda ref_d, start_d: ref_d,
+ False,
+ pgettext_lazy('reporting_timeframe', 'by week'),
+ lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
+ ),
+ (
+ 'week_previous',
+ pgettext_lazy('reporting_timeframe', 'Previous week'),
+ lambda ref_d: _week_start(ref_d) - timedelta(days=7),
+ lambda ref_d, start_d: start_d + timedelta(days=6),
+ False,
+ pgettext_lazy('reporting_timeframe', 'by week'),
+ lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
+ ),
+ (
+ 'week_next',
+ pgettext_lazy('reporting_timeframe', 'Next week'),
+ lambda ref_d: _week_start(ref_d + timedelta(days=7)),
+ lambda ref_d, start_d: start_d + timedelta(days=6),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by week'),
+ lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
+ ),
+ (
+ 'month_this',
+ pgettext_lazy('reporting_timeframe', 'Current month'),
+ lambda ref_d: ref_d.replace(day=1),
+ lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by month'),
+ lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
+ ),
+ (
+ 'month_to_date',
+ pgettext_lazy('reporting_timeframe', 'Current month to date'),
+ lambda ref_d: ref_d.replace(day=1),
+ lambda ref_d, start_d: ref_d,
+ False,
+ pgettext_lazy('reporting_timeframe', 'by month'),
+ lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
+ ),
+ (
+ 'month_previous',
+ pgettext_lazy('reporting_timeframe', 'Previous month'),
+ lambda ref_d: (ref_d.replace(day=1) - timedelta(days=1)).replace(day=1),
+ lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
+ False,
+ pgettext_lazy('reporting_timeframe', 'by month'),
+ lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
+ ),
+ (
+ 'month_next',
+ pgettext_lazy('reporting_timeframe', 'Next month'),
+ lambda ref_d: ref_d.replace(day=calendar.monthrange(ref_d.year, ref_d.month)[1]) + timedelta(days=1),
+ lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by month'),
+ lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
+ ),
+ (
+ 'quarter_this',
+ pgettext_lazy('reporting_timeframe', 'Current quarter'),
+ lambda ref_d: _quarter_start(ref_d),
+ lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by quarter'),
+ lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
+ ),
+ (
+ 'quarter_to_date',
+ pgettext_lazy('reporting_timeframe', 'Current quarter to date'),
+ lambda ref_d: _quarter_start(ref_d),
+ lambda ref_d, start_d: ref_d,
+ False,
+ pgettext_lazy('reporting_timeframe', 'by quarter'),
+ lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
+ ),
+ (
+ 'quarter_previous',
+ pgettext_lazy('reporting_timeframe', 'Previous quarter'),
+ lambda ref_d: _quarter_start(_quarter_start(ref_d) - timedelta(days=1)),
+ lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
+ False,
+ pgettext_lazy('reporting_timeframe', 'by quarter'),
+ lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
+ ),
+ (
+ 'quarter_next',
+ pgettext_lazy('reporting_timeframe', 'Next quarter'),
+ lambda ref_d: ref_d.replace(
+ day=calendar.monthrange(ref_d.year, _quarter_start(ref_d).month + 2)[1], month=_quarter_start(ref_d).month + 2
+ ) + timedelta(days=1),
+ lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by quarter'),
+ lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
+ ),
+ (
+ 'year_this',
+ pgettext_lazy('reporting_timeframe', 'Current year'),
+ lambda ref_d: ref_d.replace(day=1, month=1),
+ lambda ref_d, start_d: start_d.replace(day=31, month=12),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by year'),
+ lambda start_d, end_d: str(start_d.year),
+ ),
+ (
+ 'year_to_date',
+ pgettext_lazy('reporting_timeframe', 'Current year to date'),
+ lambda ref_d: ref_d.replace(day=1, month=1),
+ lambda ref_d, start_d: ref_d,
+ False,
+ pgettext_lazy('reporting_timeframe', 'by year'),
+ lambda start_d, end_d: str(start_d.year),
+ ),
+ (
+ 'year_previous',
+ pgettext_lazy('reporting_timeframe', 'Previous year'),
+ lambda ref_d: (ref_d.replace(day=1, month=1) - timedelta(days=1)).replace(day=1, month=1),
+ lambda ref_d, start_d: start_d.replace(day=31, month=12),
+ False,
+ pgettext_lazy('reporting_timeframe', 'by year'),
+ lambda start_d, end_d: str(start_d.year),
+ ),
+ (
+ 'year_next',
+ pgettext_lazy('reporting_timeframe', 'Next year'),
+ lambda ref_d: ref_d.replace(day=1, month=1, year=ref_d.year + 1),
+ lambda ref_d, start_d: start_d.replace(day=31, month=12),
+ True,
+ pgettext_lazy('reporting_timeframe', 'by year'),
+ lambda start_d, end_d: str(start_d.year),
+ ),
+ (
+ 'future',
+ pgettext_lazy('reporting_timeframe', 'All future (excluding today)'),
+ lambda ref_d: ref_d + timedelta(days=1),
+ lambda ref_d, start_d: None,
+ True,
+ pgettext_lazy('reporting_timeframe', 'Other'),
+ lambda start_d, end_d: date_format(start_d, "SHORT_DATE_FORMAT") + ' – ',
+ ),
+ (
+ 'past',
+ pgettext_lazy('reporting_timeframe', 'All past (including today)'),
+ lambda ref_d: None,
+ lambda ref_d, start_d: ref_d,
+ True, # technically false, but only makes sense to have in a selection that also allows the future, otherwise redundant
+ pgettext_lazy('reporting_timeframe', 'Other'),
+ lambda start_d, end_d: ' – ' + date_format(end_d, "SHORT_DATE_FORMAT"),
+ ),
+)
+
+
+class DateFrameWidget(forms.MultiWidget):
+ template_name = 'pretixbase/forms/widgets/dateframe.html'
+
+ def __init__(self, *args, **kwargs):
+ self.timeframe_choices = kwargs.pop('timeframe_choices')
+ widgets = (
+ forms.Select(choices=self.timeframe_choices),
+ forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'Start')}),
+ forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'End')}),
+ )
+ super().__init__(widgets=widgets, *args, **kwargs)
+
+ def decompress(self, value):
+ if not value:
+ return ['unset', None, None]
+ if '/' in value:
+ return [
+ 'custom',
+ date.fromisoformat(value.split('/', 1)[0]),
+ date.fromisoformat(value.split('/', 1)[-1]),
+ ]
+ return []
+
+ def get_context(self, name, value, attrs):
+ ctx = super().get_context(name, value, attrs)
+ ctx['required'] = self.timeframe_choices[0][0] == 'unset'
+ return ctx
+
+
+def _describe_timeframe(label, start, end, future, describe):
+ d_start = start(now())
+ d_end = end(now(), d_start)
+ details = describe(d_start, d_end)
+ return f'{label} ({details})'
+
+
+class DateFrameField(forms.MultiValueField):
+ default_error_messages = {
+ **forms.MultiValueField.default_error_messages,
+ 'inconsistent': gettext_lazy('The end date must be after the start date.'),
+ }
+
+ def __init__(self, *args, **kwargs):
+ include_future_frames = kwargs.pop('include_future_frames')
+
+ top_choices = [('custom', gettext_lazy('Custom timeframe'))]
+ if not kwargs.get('required', True):
+ top_choices.insert(0, ('unset', pgettext_lazy('reporting_timeframe', 'All time')))
+
+ _choices = []
+ for grouper, group in groupby(REPORTING_DATE_TIMEFRAMES, key=lambda i: i[5]):
+ options = [
+ (identifier, _describe_timeframe(label, start, end, future, describe))
+ for identifier, label, start, end, future, group, describe in group
+ if include_future_frames or not future
+ ]
+ if options:
+ _choices.append((grouper, options))
+
+ timeframe_choices = [
+ ('', top_choices)
+ ] + _choices
+
+ fields = (
+ forms.ChoiceField(
+ choices=timeframe_choices,
+ required=True
+ ),
+ forms.DateField(
+ required=False
+ ),
+ forms.DateField(
+ required=False
+ ),
+ )
+ if 'widget' not in kwargs:
+ kwargs['widget'] = DateFrameWidget(timeframe_choices=timeframe_choices)
+ kwargs.pop('max_length', 0)
+ kwargs.pop('empty_value', 0)
+ super().__init__(
+ fields=fields, require_all_fields=False, *args, **kwargs
+ )
+
+ def compress(self, data_list):
+ if not data_list:
+ return None
+ if data_list[0] == 'unset':
+ return None
+ elif data_list[0] == 'custom':
+ return f'{data_list[1].isoformat() if data_list[1] else ""}/{data_list[2].isoformat() if data_list[2] else ""}'
+ else:
+ return data_list[0]
+
+ def has_changed(self, initial, data):
+ if initial is None:
+ initial = self.widget.decompress(initial)
+ return super().has_changed(initial, data)
+
+ def clean(self, value):
+ if value[0] == 'custom':
+ if not value[1] and not value[2]:
+ raise ValidationError(self.error_messages['incomplete'])
+ if value[1] and value[2] and value[2] < value[1]:
+ raise ValidationError(self.error_messages['inconsistent'])
+ return super().clean(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
+ where the first element ist the first possible date value within the timeframe and the second
+ element is the last possible date value in the timeframe.
+ Both returned values may be ``None`` for an unlimited interval.
+ """
+ if isinstance(ref_dt, datetime):
+ ref_dt = ref_dt.astimezone(timezone).date()
+ if "/" in frame:
+ start, end = frame.split("/", 1)
+ return date.fromisoformat(start) if start else None, date.fromisoformat(end) if end else None
+ for idf, label, start, end, includes_future, *args in REPORTING_DATE_TIMEFRAMES:
+ if frame == idf:
+ d_start = start(ref_dt)
+ d_end = end(ref_dt, d_start)
+ return d_start, d_end
+ raise ValueError(f"Invalid timeframe '{frame}'")
+
+
+def resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
+ """
+ Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of datetimes
+ where the first element ist the first possible datetime within the timeframe and the second
+ element is the first possible datetime value *not* in the timeframe.
+ Both returned values may be ``None`` for an unlimited interval.
+ """
+ d_start, d_end = resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone)
+ dt_start = make_aware(datetime.combine(d_start, time(0, 0, 0)), timezone) if d_start else None
+ dt_end = make_aware(datetime.combine(d_end + timedelta(days=1), time(0, 0, 0)), timezone) if d_end else None
+ return dt_start, dt_end
diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py
index 74d2033ae7..b0878372ed 100644
--- a/src/pretix/plugins/checkinlists/exporters.py
+++ b/src/pretix/plugins/checkinlists/exporters.py
@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict
-from datetime import datetime, time, timedelta
import bleach
import dateutil.parser
@@ -44,7 +43,7 @@ from django.db.models import (
from django.db.models.functions import Coalesce, NullIf
from django.urls import reverse
from django.utils.formats import date_format
-from django.utils.timezone import is_aware, make_aware
+from django.utils.timezone import is_aware, make_aware, now
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
@@ -58,6 +57,10 @@ from pretix.base.models import (
)
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.templatetags.money import money_filter
+from pretix.base.timeframes import (
+ DateFrameField,
+ resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
+)
from pretix.control.forms.widgets import Select2
from pretix.helpers.templatetags.jsonfield import JSONExtract
from pretix.plugins.reports.exporters import ReportlabExportMixin
@@ -78,19 +81,12 @@ class CheckInListMixin(BaseExporter):
),
initial=self.event.checkin_lists.first()
)),
- ('date_from',
- forms.DateField(
- label=_('Start date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
+ ('date_range',
+ DateFrameField(
+ label=_('Date range'),
+ include_future_frames=True,
required=False,
- help_text=_('Only include tickets for dates on or after this date.')
- )),
- ('date_to',
- forms.DateField(
- label=_('End date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
- help_text=_('Only include tickets for dates on or before this date.')
+ help_text=_('Only include tickets for dates within this range.')
)),
('secrets',
forms.BooleanField(
@@ -129,8 +125,7 @@ class CheckInListMixin(BaseExporter):
)
if not self.event.has_subevents:
- del d['date_from']
- del d['date_to']
+ del d['date_range']
d['list'].queryset = self.event.checkin_lists.all()
d['list'].widget = Select2(
@@ -181,19 +176,12 @@ class CheckInListMixin(BaseExporter):
if cl.subevent:
qs = qs.filter(subevent=cl.subevent)
- if form_data.get('date_from'):
- dt = make_aware(datetime.combine(
- dateutil.parser.parse(form_data['date_from']).date(),
- time(hour=0, minute=0, second=0)
- ), self.event.timezone)
- qs = qs.filter(subevent__date_from__gte=dt)
-
- if form_data.get('date_to'):
- dt = make_aware(datetime.combine(
- dateutil.parser.parse(form_data['date_to']).date() + timedelta(days=1),
- time(hour=0, minute=0, second=0)
- ), self.event.timezone)
- qs = qs.filter(subevent__date_from__lt=dt)
+ if form_data.get('date_range'):
+ dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
+ if dt_start:
+ qs = qs.filter(subevent__date_from__gte=dt_start)
+ if dt_end:
+ qs = qs.filter(subevent__date_to__lt=dt_end)
o = ()
if self.event.has_subevents and not cl.subevent:
diff --git a/src/pretix/plugins/reports/exporters.py b/src/pretix/plugins/reports/exporters.py
index 98b7849c5b..88467ba41b 100644
--- a/src/pretix/plugins/reports/exporters.py
+++ b/src/pretix/plugins/reports/exporters.py
@@ -35,7 +35,6 @@
import copy
import tempfile
from collections import OrderedDict, defaultdict
-from datetime import date, datetime, time, timedelta
from decimal import Decimal
import pytz
@@ -47,7 +46,7 @@ from django.db import models
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
from django.template.defaultfilters import floatformat
from django.utils.formats import date_format, localize
-from django.utils.timezone import get_current_timezone, make_aware, now
+from django.utils.timezone import get_current_timezone, now
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
@@ -59,11 +58,14 @@ from reportlab.platypus import PageBreak, Paragraph, Spacer, Table, TableStyle
from pretix.base.decimal import round_decimal
from pretix.base.exporter import BaseExporter, MultiSheetListExporter
-from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import Order, OrderPosition
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee, OrderPayment
from pretix.base.services.stats import order_overview
+from pretix.base.timeframes import (
+ DateFrameField, resolve_timeframe_to_dates_inclusive,
+ resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
+)
from pretix.control.forms.filter import OverviewFilterForm
@@ -236,12 +238,13 @@ class OverviewReport(Report):
def _filter_story(self, doc, form_data, net=False):
story = []
- if form_data.get('date_axis') and (form_data.get('date_from') or form_data.get('date_until')):
+ if form_data.get('date_axis') and form_data.get('date_range'):
+ d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
story += [
Paragraph(_('{axis} between {start} and {end}').format(
axis=dict(OverviewFilterForm(event=self.event).fields['date_axis'].choices)[form_data.get('date_axis')],
- start=date_format(form_data.get('date_from'), 'SHORT_DATE_FORMAT') if form_data.get('date_from') else '–',
- end=date_format(form_data.get('date_until'), 'SHORT_DATE_FORMAT') if form_data.get('date_until') else '–',
+ start=date_format(d_start, 'SHORT_DATE_FORMAT') if d_start else '–',
+ end=date_format(d_end, 'SHORT_DATE_FORMAT') if d_end else '–',
), self.get_style()),
Spacer(1, 5 * mm)
]
@@ -256,12 +259,16 @@ class OverviewReport(Report):
return story
def _get_data(self, form_data):
+ if form_data.get('date_range'):
+ d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
+ else:
+ d_start, d_end = None, None
return order_overview(
self.event,
subevent=form_data.get('subevent'),
date_filter=form_data.get('date_axis'),
- date_from=form_data.get('date_from'),
- date_until=form_data.get('date_until'),
+ date_from=d_start,
+ date_until=d_end,
fees=True
)
@@ -381,6 +388,13 @@ class OverviewReport(Report):
def export_form_fields(self) -> dict:
f = OverviewFilterForm(event=self.event)
del f.fields['ordering']
+ del f.fields['date_from']
+ del f.fields['date_until']
+ f.fields['date_range'] = DateFrameField(
+ label=_('Date range'),
+ include_future_frames=False,
+ required=False,
+ )
return f.fields
@@ -606,48 +620,30 @@ class OrderTaxListReport(MultiSheetListExporter):
),
required=False,
)),
- ('date_from', forms.DateField(
- label=_('Date from'),
- required=False,
- widget=DatePickerWidget,
- )),
- ('date_until', forms.DateField(
- label=_('Date until'),
- required=False,
- widget=DatePickerWidget,
- ))
+ ('date_range',
+ DateFrameField(
+ label=_('Date range'),
+ include_future_frames=False,
+ required=False,
+ help_text=_('Only include orders created within this date range.')
+ )),
]
))
return f
def filter_qs(self, qs, form_data):
- date_from = form_data.get('date_from')
- date_until = form_data.get('date_until')
+ date_range = form_data.get('date_range')
date_filter = form_data.get('date_axis')
- if date_from:
- if isinstance(date_from, str):
- date_from = parse(date_from).date()
- if isinstance(date_from, date):
- date_from = make_aware(datetime.combine(
- date_from,
- time(hour=0, minute=0, second=0, microsecond=0)
- ), self.event.timezone)
- if date_until:
- if isinstance(date_until, str):
- date_until = parse(date_until).date()
- if isinstance(date_until, date):
- date_until = make_aware(datetime.combine(
- date_until + timedelta(days=1),
- time(hour=0, minute=0, second=0, microsecond=0)
- ), self.event.timezone)
+ if date_range:
+ dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.timezone)
- if date_filter == 'order_date':
- if date_from:
- qs = qs.filter(order__datetime__gte=date_from)
- if date_until:
- qs = qs.filter(order__datetime__lt=date_until)
- elif date_filter == 'last_payment_date':
+ if date_filter == 'order_date' and date_range:
+ if dt_start:
+ qs = qs.filter(order__datetime__gte=dt_start)
+ if dt_end:
+ qs = qs.filter(order__datetime__lt=dt_end)
+ elif date_filter == 'last_payment_date' and date_range:
p_date = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED],
@@ -656,10 +652,10 @@ class OrderTaxListReport(MultiSheetListExporter):
m=Max('payment_date')
).values('m').order_by()
qs = qs.annotate(payment_date=Subquery(p_date, output_field=DateTimeField()))
- if date_from:
- qs = qs.filter(payment_date__gte=date_from)
- if date_until:
- qs = qs.filter(payment_date__lt=date_until)
+ if dt_start:
+ qs = qs.filter(payment_date__gte=dt_start)
+ if dt_end:
+ qs = qs.filter(payment_date__lt=dt_end)
return qs
def iterate_sheet(self, form_data, sheet):
diff --git a/src/pretix/plugins/ticketoutputpdf/exporters.py b/src/pretix/plugins/ticketoutputpdf/exporters.py
index f20ddee68b..5cd170ecd7 100644
--- a/src/pretix/plugins/ticketoutputpdf/exporters.py
+++ b/src/pretix/plugins/ticketoutputpdf/exporters.py
@@ -34,16 +34,14 @@
import logging
from collections import OrderedDict
-from datetime import datetime, time, timedelta
from io import BytesIO
-import dateutil.parser
from django import forms
from django.core.files.base import ContentFile
from django.db import DataError, models
from django.db.models import OuterRef, Q, Subquery
from django.db.models.functions import Cast, Coalesce
-from django.utils.timezone import make_aware
+from django.utils.timezone import now
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from PyPDF2 import PdfMerger
@@ -55,6 +53,10 @@ from pretix.base.models import (
from pretix.base.settings import PERSON_NAME_SCHEMES
from ...base.services.export import ExportError
+from ...base.timeframes import (
+ DateFrameField,
+ resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
+)
from ...helpers.templatetags.jsonfield import JSONExtract
from .ticketoutput import PdfTicketOutput
@@ -78,19 +80,12 @@ class AllTicketsPDF(BaseExporter):
label=_('Include pending orders'),
required=False
)),
- ('date_from',
- forms.DateField(
- label=_('Start date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
+ ('date_range',
+ DateFrameField(
+ label=_('Date range'),
+ include_future_frames=True,
required=False,
- help_text=_('Only include tickets for dates on or after this date.')
- )),
- ('date_to',
- forms.DateField(
- label=_('End date'),
- widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
- required=False,
- help_text=_('Only include tickets for dates on or before this date.')
+ help_text=_('Only include tickets for dates within this range.')
)),
('order_by',
forms.ChoiceField(
@@ -117,8 +112,7 @@ class AllTicketsPDF(BaseExporter):
)
if not self.is_multievent and not self.event.has_subevents:
- del d['date_from']
- del d['date_to']
+ del d['date_range']
return d
@@ -135,19 +129,12 @@ class AllTicketsPDF(BaseExporter):
else:
qs = qs.filter(order__status__in=[Order.STATUS_PAID])
- if form_data.get('date_from'):
- dt = make_aware(datetime.combine(
- dateutil.parser.parse(form_data['date_from']).date(),
- time(hour=0, minute=0, second=0)
- ), self.timezone)
- qs = qs.filter(Q(subevent__date_from__gte=dt) | Q(subevent__isnull=True, order__event__date_from__gte=dt))
-
- if form_data.get('date_to'):
- dt = make_aware(datetime.combine(
- dateutil.parser.parse(form_data['date_to']).date() + timedelta(days=1),
- time(hour=0, minute=0, second=0)
- ), self.timezone)
- qs = qs.filter(Q(subevent__date_from__lt=dt) | Q(subevent__isnull=True, order__event__date_from__lt=dt))
+ if form_data.get('date_range'):
+ dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
+ if dt_start:
+ qs = qs.filter(Q(subevent__date_from__gte=dt_start) | Q(subevent__isnull=True, order__event__date_from__gte=dt_start))
+ if dt_end:
+ qs = qs.filter(Q(subevent__date_from__lt=dt_end) | Q(subevent__isnull=True, order__event__date_from__lt=dt_end))
if form_data.get('order_by') == 'name':
qs = qs.order_by('attendee_name_cached', 'order__code')
diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js
index 7a92c5e21c..471e792f74 100644
--- a/src/pretix/static/pretixcontrol/js/ui/main.js
+++ b/src/pretix/static/pretixcontrol/js/ui/main.js
@@ -355,7 +355,16 @@ var form_handlers = function (el) {
var dependent = $(this),
dependency = $($(this).attr("data-display-dependency")),
update = function (ev) {
- var enabled = dependency.toArray().some(function(d) {return (d.type === 'checkbox' || d.type === 'radio') ? d.checked : (!!d.value && !d.value.match(/^0\.?0*$/g))});
+ var enabled = dependency.toArray().some(function(d) {
+ if (d.type === 'checkbox' || d.type === 'radio') {
+ return d.checked;
+ } else if (d.type === 'select-one') {
+ return d.value === dependent.attr("data-display-dependency-value");
+ } else {
+ return (!!d.value && !d.value.match(/^0\.?0*$/g));
+ }
+ });
+ console.log(dependent, dependency, enabled)
if (dependent.is("[data-inverse]")) {
enabled = !enabled;
}
diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js
index 3b0aa9dd4b..731ad61403 100644
--- a/src/pretix/static/pretixpresale/js/ui/main.js
+++ b/src/pretix/static/pretixpresale/js/ui/main.js
@@ -457,7 +457,7 @@ $(function () {
}
};
update();
- dependency.closest('.form-group, form').find('input[name=' + dependency.attr("name") + ']').on("change", update);
+ dependency.closest('.form-group, form').find('input[name=' + dependency.attr("name") + '], select[name=' + dependency.attr("name") + ']').on("change", update);
dependency.closest('.form-group, form').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update);
});
diff --git a/src/tests/api/test_exporters.py b/src/tests/api/test_exporters.py
index 7c49cb364e..f42ed2eda2 100644
--- a/src/tests/api/test_exporters.py
+++ b/src/tests/api/test_exporters.py
@@ -72,19 +72,11 @@ SAMPLE_EXPORTER_CONFIG = {
"required": False
},
{
- "name": "date_from",
+ "name": "date_range",
"required": False
},
{
- "name": "date_to",
- "required": False
- },
- {
- "name": "event_date_from",
- "required": False
- },
- {
- "name": "event_date_to",
+ "name": "event_date_range",
"required": False
},
]
diff --git a/src/tests/base/test_timeframes.py b/src/tests/base/test_timeframes.py
new file mode 100644
index 0000000000..0f9f454d98
--- /dev/null
+++ b/src/tests/base/test_timeframes.py
@@ -0,0 +1,110 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+
+from datetime import date, datetime
+
+import pytest
+import pytz
+
+from pretix.base.timeframes import (
+ REPORTING_DATE_TIMEFRAMES, resolve_timeframe_to_dates_inclusive,
+ resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
+)
+
+tz = pytz.timezone("Europe/Berlin")
+
+
+def dt(*args):
+ return tz.localize(datetime(*args))
+
+
+ref_date = date(2023, 3, 28)
+
+
+@pytest.mark.parametrize("ref_dt,identifier,expected_start,expected_end,description", [
+ (ref_date, 'days_today', date(2023, 3, 28), date(2023, 3, 28), None),
+ (ref_date, 'days_yesterday', date(2023, 3, 27), date(2023, 3, 27), None),
+ (ref_date, 'days_last7', date(2023, 3, 22), date(2023, 3, 28), None),
+ (ref_date, 'days_last14', date(2023, 3, 15), date(2023, 3, 28), None),
+ (ref_date, 'days_tomorrow', date(2023, 3, 29), date(2023, 3, 29), None),
+ (ref_date, 'days_next7', date(2023, 3, 29), date(2023, 4, 4), None),
+ (ref_date, 'days_next14', date(2023, 3, 29), date(2023, 4, 11), None),
+ (ref_date, 'week_this', date(2023, 3, 27), date(2023, 4, 2), 'W 13, 2023 - March 27th – April 2nd, 2023'),
+ (ref_date, 'week_to_date', date(2023, 3, 27), date(2023, 3, 28), 'W 13, 2023 - March 27th – 28th, 2023'),
+ (ref_date, 'week_previous', date(2023, 3, 20), date(2023, 3, 26), 'W 12, 2023 - March 20th – 26th, 2023'),
+ (ref_date, 'week_next', date(2023, 4, 3), date(2023, 4, 9), 'W 14, 2023 - April 3rd – 9th, 2023'),
+ (ref_date, 'month_this', date(2023, 3, 1), date(2023, 3, 31), 'March 2023'),
+ (ref_date, 'month_to_date', date(2023, 3, 1), date(2023, 3, 28), 'March 2023'),
+ (ref_date, 'month_previous', date(2023, 2, 1), date(2023, 2, 28), 'February 2023'),
+ (ref_date, 'month_next', date(2023, 4, 1), date(2023, 4, 30), 'April 2023'),
+ (ref_date, 'quarter_this', date(2023, 1, 1), date(2023, 3, 31), 'Q1/2023'),
+ (ref_date, 'quarter_to_date', date(2023, 1, 1), date(2023, 3, 28), 'Q1/2023'),
+ (ref_date, 'quarter_previous', date(2022, 10, 1), date(2022, 12, 31), 'Q4/2022'),
+ (ref_date, 'quarter_next', date(2023, 4, 1), date(2023, 6, 30), 'Q2/2023'),
+ (ref_date, 'year_this', date(2023, 1, 1), date(2023, 12, 31), '2023'),
+ (ref_date, 'year_to_date', date(2023, 1, 1), date(2023, 3, 28), '2023'),
+ (ref_date, 'year_previous', date(2022, 1, 1), date(2022, 12, 31), '2022'),
+ (ref_date, 'year_next', date(2024, 1, 1), date(2024, 12, 31), '2024'),
+ (ref_date, 'future', date(2023, 3, 29), None, '2023-03-29 – '),
+ (ref_date, 'past', None, date(2023, 3, 28), ' – 2023-03-28'),
+])
+def test_timeframe(ref_dt, identifier, expected_start, expected_end, description):
+ for idf, label, start, end, includes_future, group, describe in REPORTING_DATE_TIMEFRAMES:
+ if identifier == idf:
+ assert start(ref_dt) == expected_start
+ assert end(ref_dt, expected_start) == expected_end
+ if expected_end and expected_start:
+ assert includes_future == (expected_end > ref_dt)
+ if description:
+ assert describe(expected_start, expected_end) == description
+ break
+ else:
+ assert False, "identifier not found"
+
+
+def test_resolve():
+ assert resolve_timeframe_to_dates_inclusive(ref_date, "week_previous", tz) == (
+ date(2023, 3, 20),
+ date(2023, 3, 26),
+ )
+ assert resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_date, "week_previous", tz) == (
+ dt(2023, 3, 20, 0, 0, 0, 0),
+ dt(2023, 3, 27, 0, 0, 0, 0),
+ )
+
+ assert resolve_timeframe_to_dates_inclusive(ref_date, "2023-03-20/2023-03-21", tz) == (
+ date(2023, 3, 20),
+ date(2023, 3, 21),
+ )
+ assert resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_date, "2023-03-20/2023-03-21", tz) == (
+ dt(2023, 3, 20, 0, 0, 0, 0),
+ dt(2023, 3, 22, 0, 0, 0, 0),
+ )
+
+ assert resolve_timeframe_to_dates_inclusive(ref_date, "2023-03-20/", tz) == (
+ date(2023, 3, 20),
+ None
+ )
+ assert resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_date, "2023-03-20/", tz) == (
+ dt(2023, 3, 20, 0, 0, 0, 0),
+ None
+ )