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 + )