diff --git a/src/pretix/base/forms/widgets.py b/src/pretix/base/forms/widgets.py index 598527a16d..ccca7a8160 100644 --- a/src/pretix/base/forms/widgets.py +++ b/src/pretix/base/forms/widgets.py @@ -314,3 +314,18 @@ class BusinessBooleanRadio(forms.RadioSelect): 'False': False, False: False, }.get(value) + + +class OptionAttrsSelect(forms.Select): + def __init__(self, *args, option_attrs=None, **kwargs): + super().__init__(*args, **kwargs) + self.option_attrs = option_attrs or {} + + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + option = super().create_option( + name, value, label, selected, index, subindex=subindex, attrs=attrs + ) + extra = self.option_attrs.get(str(value)) + if extra: + option["attrs"].update(extra) + return option diff --git a/src/pretix/base/reldate.py b/src/pretix/base/reldate.py index 3b67610d5a..510a4b4661 100644 --- a/src/pretix/base/reldate.py +++ b/src/pretix/base/reldate.py @@ -19,30 +19,245 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import copy import datetime +import os +import warnings from collections import namedtuple -from typing import Union +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, Iterable, List, Literal, Optional, Tuple, Union, cast, +) from zoneinfo import ZoneInfo from dateutil import parser from django import forms from django.core.exceptions import ValidationError from django.db import models +from django.forms.widgets import Widget from django.utils.formats import get_format -from django.utils.functional import lazy +from django.utils.functional import Promise, lazy from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -BASE_CHOICES = ( - ('date_from', _('Event start')), - ('date_to', _('Event end')), - ('date_admission', _('Event admission')), - ('presale_start', _('Presale start')), - ('presale_end', _('Presale end')), -) +from pretix.base.forms.widgets import OptionAttrsSelect -RelativeDate = namedtuple('RelativeDate', ['days', 'minutes', 'time', 'is_after', 'base_date_name'], defaults=(0, None, None, False, 'date_from')) +if TYPE_CHECKING: + from .models import Event, Order, SubEvent + + +@dataclass(frozen=True) +class BaseChoice: + base: Literal["event", "order"] + attribute: str + text: Promise + supports_before: bool + supports_after: bool + + @property + def key(self) -> str: + key = f"{self.base}__{self.attribute}" + return key + + @staticmethod + def find(objects: Iterable["BaseChoice"], key: str) -> "BaseChoice": + if "__" in key: + choice = next((obj for obj in objects if obj.key == key), None) + else: + # fallback for RelativeDateFields stored, before support for bases other than event was added + choice = next((obj for obj in objects if obj.attribute == key and obj.base == "event"), None) + + if choice is None: + raise TypeError(f"key {key} must be a valid key in BASE_CHOICES") + + return choice + + +BASE_CHOICES: List[BaseChoice] = [ + BaseChoice('event', 'date_from', _('Event start'), True, True), + BaseChoice('event', 'date_to', _('Event end'), True, True), + BaseChoice('event', 'date_admission', _('Event admission'), True, True), + BaseChoice('event', 'presale_start', _('Presale start'), True, True), + BaseChoice('event', 'presale_end', _('Presale end'), True, True), + BaseChoice('order', 'datetime', _('Order creation'), False, True), + BaseChoice('order', 'expires', _('Order expiry'), True, True), +] + +EVENT_BASE_CHOICES = [ + x for x in BASE_CHOICES if x.base == 'event' +] + +ORDER_BASE_CHOICES = [ + x for x in BASE_CHOICES if x.base == 'order' +] + + +@dataclass(frozen=True) +class RelativeDate: + """ + This contains information on a date that is defined in relation to a fixed base point. + This means that the underlying data is a fixed date as the base point and a number of days or a time interval + to calculate the date. + + The list of valid base date choices is defined in BASE_CHOICES. + If the base_date_name is not set, the date_from attribute of Event is used. + """ + + days: int = 0 + minutes: Optional[int] = None + time: Optional[datetime.time] = None + is_after: bool = False + base_date_name: str = 'event__date_from__' + + def __post_init__(self) -> None: + if self.is_after and not self._choice.supports_after: + raise ValueError( + "The selected base date and attribute combination does not support relative dates placed after the base date" + ) + if not self.is_after and not self._choice.supports_before: + raise ValueError( + "The selected base date and attribute combination does not support relative dates placed before the base date") + + @property + def _choice(self): + return BaseChoice.find(BASE_CHOICES, self.base_date_name) + + @property + def key(self): + return self._choice.key + + def __eq__(self, o: object) -> bool: + if not isinstance(o, RelativeDate): + return False + return self.to_string() == o.to_string() + + def _resolve_date(self, base: "Event | Order | SubEvent") -> Tuple[datetime.datetime, ZoneInfo]: + """ + Resolves the datetime and timezone information of this RelativeDate object in relation to the provided reference. + + :param base: the reference which should be used to resolve the relative date + :return: + """ + from .models import Event, Order, SubEvent + + choice = self._choice + + if choice.base == "order" and isinstance(base, Order): + event = base.event + base_date = getattr(base, choice.attribute) + elif choice.base == "event" and isinstance(base, SubEvent): + event = base.event + base_date = (getattr(base, choice.attribute) or + getattr(base.event, choice.attribute) or + base.date_from) + elif choice.base == "event" and isinstance(base, Event): + event = base + base_date = getattr(base, choice.attribute) or event.date_from + else: + raise TypeError("The base defined by data does not match the passed in base") + + tz = ZoneInfo(event.settings.timezone) + return base_date, tz + + def date(self, base: "Event | Order | SubEvent") -> datetime.date: + """ + Resolves the effective date of this RelativeDate object in relation to the provided reference. + + :param base: the reference which should be used to resolve the date + :return: datetime.date + """ + if self.minutes is not None: + raise ValueError('A minute-based relative datetime can not be used as a date') + + base_date, tz = self._resolve_date(base) + + if self.is_after: + new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.days) + else: + new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.days) + return new_date.date() + + def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime: + """ + Resolves the effective datetime of this RelativeDate object in relation to the provided reference. + + :param base: the reference which should be used to resolve the datetime + :return: datetime.datetime + """ + base_date, tz = self._resolve_date(base) + + if self.minutes is not None: + if self.is_after: + return base_date.astimezone(tz) + datetime.timedelta(minutes=self.minutes) + else: + return base_date.astimezone(tz) - datetime.timedelta(minutes=self.minutes) + else: + if self.is_after: + new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.days)).astimezone(tz) + else: + new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.days)).astimezone(tz) + if self.time: + new_date = new_date.replace( + hour=self.time.hour, + minute=self.time.minute, + second=self.time.second + ) + new_date = new_date.astimezone(tz) + return new_date + + def to_string(self) -> str: + if self.minutes is not None: + return 'RELDATE/minutes/{}/{}/{}'.format( # + self.minutes, + self._choice.key, + 'after' if self.is_after else '', + ) + return 'RELDATE/{}/{}/{}/{}'.format( # + self.days, + self.time.strftime('%H:%M:%S') if self.time else '-', + self._choice.key, + 'after' if self.is_after else '', + ) + + @classmethod + def from_string(cls, input: str): + if not input.startswith('RELDATE/'): + raise TypeError("Invalid input for RelativeDate.from_string()") + + parts = input.split('/') + if parts[1] == 'minutes': + data = RelativeDate( + days=0, + minutes=int(parts[2]), + base_date_name=parts[3], + time=None, + is_after=len(parts) > 4 and parts[4] == "after", + ) + else: + if parts[2] == '-': + time = None + else: + timeparts = parts[2].split(':') + time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2])) + try: + data = RelativeDate( + days=int(parts[1] or 0), + base_date_name=parts[3], + time=time, + minutes=None, + is_after=len(parts) > 4 and parts[4] == "after", + ) + except ValueError: + data = RelativeDate( + days=0, + base_date_name=parts[3], + time=time, + minutes=None, + is_after=len(parts) > 4 and parts[4] == "after", + ) + + return data class RelativeDateWrapper: @@ -50,130 +265,53 @@ class RelativeDateWrapper: This contains information on a date that might be relative to an event. This means that the underlying data is either a fixed date or a number of days and a wall clock time to calculate the date based on a base point. - - The base point can be the date_from, date_to, date_admission, presale_start or presale_end - attribute of an event or subevent. If the respective attribute is not set, ``date_from`` - will be used. """ def __init__(self, data: Union[datetime.datetime, RelativeDate]): self.data = data - def date(self, event) -> datetime.date: - from .models import SubEvent + def date(self, base: "Event | Order | SubEvent") -> datetime.date: + """ + If the RelativeDateWrapper wraps a RelativeDate object: + Resolves the effective date of this object in relation to the provided reference. + If the RelativeDateWrapper wraps an absolute date or datetime: + Returns the wrapped absolute date. + :param base: the reference which should be used to resolve the date in case it is a relative date. + :return: datetime.date + """ if isinstance(self.data, datetime.datetime): return self.data.date() elif isinstance(self.data, datetime.date): return self.data else: - if self.data.minutes is not None: - raise ValueError('A minute-based relative datetime can not be used as a date') + return self.data.date(base) - tz = ZoneInfo(event.settings.timezone) - if isinstance(event, SubEvent): - base_date = ( - getattr(event, self.data.base_date_name) - or getattr(event.event, self.data.base_date_name) - or event.date_from - ) - else: - base_date = getattr(event, self.data.base_date_name) or event.date_from - - if self.data.is_after: - new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.data.days) - else: - new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days) - return new_date.date() - - def datetime(self, event) -> datetime.datetime: - from .models import SubEvent + def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime: + """ + If the RelativeDateWrapper wraps a RelativeDate object: + Resolves the effective datetime of this object in relation to the provided reference. + If the RelativeDateWrapper wraps an absolute date or datetime: + Returns the wrapped absolute datetime. + :param base: the reference which should be used to resolve the datetime in case it is a relative datetime. + :return: datetime.datetime + """ if isinstance(self.data, (datetime.datetime, datetime.date)): return self.data else: - tz = ZoneInfo(event.settings.timezone) - if isinstance(event, SubEvent): - base_date = ( - getattr(event, self.data.base_date_name) - or getattr(event.event, self.data.base_date_name) - or event.date_from - ) - else: - base_date = getattr(event, self.data.base_date_name) or event.date_from - - if self.data.minutes is not None: - if self.data.is_after: - return base_date.astimezone(tz) + datetime.timedelta(minutes=self.data.minutes) - else: - return base_date.astimezone(tz) - datetime.timedelta(minutes=self.data.minutes) - else: - if self.data.is_after: - new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)).astimezone(tz) - else: - new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)).astimezone(tz) - if self.data.time: - new_date = new_date.replace( - hour=self.data.time.hour, - minute=self.data.time.minute, - second=self.data.time.second - ) - new_date = new_date.astimezone(tz) - return new_date + return self.data.datetime(base) def to_string(self) -> str: if isinstance(self.data, (datetime.datetime, datetime.date)): return self.data.isoformat() else: - if self.data.minutes is not None: - return 'RELDATE/minutes/{}/{}/{}'.format( # - self.data.minutes, - self.data.base_date_name, - 'after' if self.data.is_after else '', - ) - return 'RELDATE/{}/{}/{}/{}'.format( # - self.data.days, - self.data.time.strftime('%H:%M:%S') if self.data.time else '-', - self.data.base_date_name, - 'after' if self.data.is_after else '', - ) + return self.data.to_string() @classmethod def from_string(cls, input: str): if input.startswith('RELDATE/'): - parts = input.split('/') - if parts[1] == 'minutes': - data = RelativeDate( - days=0, - minutes=int(parts[2]), - base_date_name=parts[3], - time=None, - is_after=len(parts) > 4 and parts[4] == "after", - ) - else: - if parts[2] == '-': - time = None - else: - timeparts = parts[2].split(':') - time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2])) - try: - data = RelativeDate( - days=int(parts[1] or 0), - base_date_name=parts[3], - time=time, - minutes=None, - is_after=len(parts) > 4 and parts[4] == "after", - ) - except ValueError: - data = RelativeDate( - days=0, - base_date_name=parts[3], - time=time, - minutes=None, - is_after=len(parts) > 4 and parts[4] == "after", - ) - if data.base_date_name not in [k[0] for k in BASE_CHOICES]: - raise ValueError('{} is not a valid base date'.format(data.base_date_name)) + data = RelativeDate.from_string(input) else: data = parser.parse(input) return RelativeDateWrapper(data) @@ -187,7 +325,6 @@ BEFORE_AFTER_CHOICE = ( ('after', _('after')), ) - reldatetimeparts = namedtuple('reldatetimeparts', ( "status", # 0 "absolute", # 1 @@ -202,6 +339,14 @@ reldatetimeparts = namedtuple('reldatetimeparts', ( reldatetimeparts.indizes = reldatetimeparts(*range(9)) +def _get_choices(base_choices: List[BaseChoice]) -> List[Tuple[str, Promise]]: + return [(c.key, c.text) for c in base_choices] + + +def _get_choice_validation_obj(choices: List[BaseChoice]): + return {c.key: {"data-supports-before": c.supports_before, "data-supports-after": c.supports_after} for c in choices} + + class RelativeDateTimeWidget(forms.MultiWidget): template_name = 'pretixbase/forms/widgets/reldatetime.html' parts = reldatetimeparts @@ -209,6 +354,7 @@ class RelativeDateTimeWidget(forms.MultiWidget): def __init__(self, *args, **kwargs): self.status_choices = kwargs.pop('status_choices') base_choices = kwargs.pop('base_choices') + choices = _get_choices(base_choices) def placeholder_datetime_format(): df = get_format('DATETIME_INPUT_FORMATS')[0] @@ -220,21 +366,29 @@ class RelativeDateTimeWidget(forms.MultiWidget): tf = get_format('TIME_INPUT_FORMATS')[0] return datetime.time(8, 30, 0).strftime(tf) - widgets = reldatetimeparts( + widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldatetimeparts( status=forms.RadioSelect(choices=self.status_choices), absolute=forms.DateTimeInput( attrs={'placeholder': lazy(placeholder_datetime_format, str), 'class': 'datetimepicker'} ), rel_days_number=forms.NumberInput(), - rel_mins_relationto=forms.Select(choices=base_choices), + rel_mins_relationto=OptionAttrsSelect( + attrs={'data-relative-choice': True}, + choices=choices, + option_attrs=_get_choice_validation_obj(base_choices) + ), rel_days_timeofday=forms.TimeInput( attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'} ), rel_mins_number=forms.NumberInput(), - rel_days_relationto=forms.Select(choices=base_choices), - rel_mins_relation=forms.Select(choices=BEFORE_AFTER_CHOICE), - rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE), - ) + rel_days_relationto=OptionAttrsSelect( + attrs={'data-relative-choice': True}, + choices=choices, + option_attrs=_get_choice_validation_obj(base_choices) + ), + rel_mins_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE), + rel_days_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE), + ))) super().__init__(widgets=widgets, *args, **kwargs) def decompress(self, value): @@ -247,10 +401,10 @@ class RelativeDateTimeWidget(forms.MultiWidget): status="unset", absolute=None, rel_days_number=1, - rel_mins_relationto="date_from", + rel_mins_relationto="event__date_from", rel_days_timeofday=None, rel_mins_number=0, - rel_days_relationto="date_from", + rel_days_relationto="event__date_from", rel_mins_relation="before", rel_days_relation="before" ) @@ -259,10 +413,10 @@ class RelativeDateTimeWidget(forms.MultiWidget): status="absolute", absolute=value.data, rel_days_number=1, - rel_mins_relationto="date_from", + rel_mins_relationto="event__date_from", rel_days_timeofday=None, rel_mins_number=0, - rel_days_relationto="date_from", + rel_days_relationto="event__date_from", rel_mins_relation="before", rel_days_relation="before" ) @@ -271,10 +425,10 @@ class RelativeDateTimeWidget(forms.MultiWidget): status="relative_minutes", absolute=None, rel_days_number=None, - rel_mins_relationto=value.data.base_date_name, + rel_mins_relationto=value.data.key, rel_days_timeofday=None, rel_mins_number=value.data.minutes, - rel_days_relationto=value.data.base_date_name, + rel_days_relationto=value.data.key, rel_mins_relation="after" if value.data.is_after else "before", rel_days_relation="after" if value.data.is_after else "before" ) @@ -282,10 +436,10 @@ class RelativeDateTimeWidget(forms.MultiWidget): status="relative", absolute=None, rel_days_number=value.data.days, - rel_mins_relationto=value.data.base_date_name, + rel_mins_relationto=value.data.key, rel_days_timeofday=value.data.time, rel_mins_number=0, - rel_days_relationto=value.data.base_date_name, + rel_days_relationto=value.data.key, rel_mins_relation="after" if value.data.is_after else "before", rel_days_relation="after" if value.data.is_after else "before" ) @@ -309,17 +463,41 @@ class RelativeDateTimeField(forms.MultiValueField): ('relative', _('Relative date:')), ('relative_minutes', _('Relative time:')), ] + self.relative_to_order = kwargs.pop('relative_to_order', False) + + possible_choices = copy.deepcopy(EVENT_BASE_CHOICES) + if self.relative_to_order: + possible_choices.extend(ORDER_BASE_CHOICES) + if kwargs.get('limit_choices'): limit = kwargs.pop('limit_choices') - choices = [(k, v) for k, v in BASE_CHOICES if k in limit] - else: - choices = BASE_CHOICES + if any(["__" not in l for l in limit]): + _warn_skips = (str(os.path.dirname(__file__)),) + + warnings.warn( + message="Please prefix limit_choices with the base the attributes refer to, for example event__date_from", + category=DeprecationWarning, + skip_file_prefixes=_warn_skips + ) + + possible_choices = [ + c for c in possible_choices if + # new base case as we want limit_choices to be expressed as base__attribute + (c.key in limit) or + # fallback for old event based entries + # if the base is an event, then using only attribute is fine + (c.base == "event" and c.attribute in limit) + ] + if not kwargs.get('required', True): status_choices.insert(0, ('unset', _('Not set'))) + + choices = _get_choices(possible_choices) + fields = reldatetimeparts( status=forms.ChoiceField( choices=status_choices, - required=True + required=True, ), absolute=forms.DateTimeField( required=False @@ -329,7 +507,7 @@ class RelativeDateTimeField(forms.MultiValueField): ), rel_mins_relationto=forms.ChoiceField( choices=choices, - required=False + required=False, ), rel_days_timeofday=forms.TimeField( required=False, @@ -339,7 +517,7 @@ class RelativeDateTimeField(forms.MultiValueField): ), rel_days_relationto=forms.ChoiceField( choices=choices, - required=False + required=False, ), rel_mins_relation=forms.ChoiceField( choices=BEFORE_AFTER_CHOICE, @@ -350,8 +528,9 @@ class RelativeDateTimeField(forms.MultiValueField): required=False ), ) + if 'widget' not in kwargs: - kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=choices) + kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=possible_choices) kwargs.pop('max_length', 0) kwargs.pop('empty_value', 0) super().__init__( @@ -359,21 +538,24 @@ class RelativeDateTimeField(forms.MultiValueField): ) def set_event(self, event): - self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = [ - (k, v) for k, v in BASE_CHOICES if getattr(event, k, None) - ] - self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = [ - (k, v) for k, v in BASE_CHOICES if getattr(event, k, None) - ] + possible_choices = copy.deepcopy(EVENT_BASE_CHOICES) + if self.relative_to_order: + possible_choices.extend(ORDER_BASE_CHOICES) + + possible_choices = possible_choices + choices = _get_choices(possible_choices) + + self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = choices + self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = choices def compress(self, data_list): if not data_list: return None data = reldatetimeparts(*data_list) - if data.status == 'absolute': - return RelativeDateWrapper(data.absolute) - elif data.status == 'unset': + if data.status == 'unset': return None + elif data.status == 'absolute': + return RelativeDateWrapper(data.absolute) elif data.status == 'relative_minutes': return RelativeDateWrapper(RelativeDate( days=0, @@ -404,6 +586,18 @@ class RelativeDateTimeField(forms.MultiValueField): raise ValidationError(self.error_messages['incomplete']) elif data.status == 'relative_minutes' and (data.rel_mins_number is None or not data.rel_mins_relationto): raise ValidationError(self.error_messages['incomplete']) + elif data.status == 'relative': + choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto) + if data.rel_days_relation == "before" and not choice.supports_before: + raise ValidationError(_('A relative date cannot be expressed as "before" for "{}"'.format(choice.text))) + elif data.status == 'relative' and data.rel_days_relation == "after" and not choice.supports_after: + raise ValidationError(_('A relative date cannot be expressed as "after" for "{}"'.format(choice.text))) + elif data.status == 'relative_minutes': + choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto) + if data.rel_days_relation == "before" and not choice.supports_before: + raise ValidationError(_('A relative time cannot be expressed as "before" for "{}"'.format(choice.text))) + elif data.rel_days_relation == "after" and not choice.supports_after: + raise ValidationError(_('A relative time cannot be expressed as "after" for "{}"'.format(choice.text))) return super().clean(value) @@ -424,15 +618,20 @@ class RelativeDateWidget(RelativeDateTimeWidget): def __init__(self, *args, **kwargs): self.status_choices = kwargs.pop('status_choices') - widgets = reldateparts( + self.base_choices = kwargs.pop('base_choices') + widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldateparts( status=forms.RadioSelect(choices=self.status_choices), absolute=forms.DateInput( attrs={'class': 'datepickerfield'} ), rel_days_number=forms.NumberInput(), - rel_days_relationto=forms.Select(choices=kwargs.pop('base_choices')), - rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE), - ) + rel_days_relationto=OptionAttrsSelect( + choices=self.base_choices, + option_attrs=_get_choice_validation_obj(self.base_choices), + attrs={'data-relative-choice': True}, + ), + rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE, attrs={'data-relation-choice': True},), + ))) forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs) def decompress(self, value): @@ -460,7 +659,7 @@ class RelativeDateWidget(RelativeDateTimeWidget): status="relative", absolute=None, rel_days_number=value.data.days, - rel_days_relationto=value.data.base_date_name, + rel_days_relationto=value.data.key, rel_days_relation="after" if value.data.is_after else "before" ) @@ -474,6 +673,15 @@ class RelativeDateField(RelativeDateTimeField): ] if not kwargs.get('required', True): status_choices.insert(0, ('unset', _('Not set'))) + + self.relative_to_order = kwargs.pop('relative_to_order', False) + + possible_choices = copy.deepcopy(EVENT_BASE_CHOICES) + if self.relative_to_order: + possible_choices.extend(ORDER_BASE_CHOICES) + + choices = _get_choices(possible_choices) + fields = reldateparts( status=forms.ChoiceField( choices=status_choices, @@ -486,33 +694,37 @@ class RelativeDateField(RelativeDateTimeField): required=False ), rel_days_relationto=forms.ChoiceField( - choices=BASE_CHOICES, - required=False + choices=choices, + required=False, ), rel_days_relation=forms.ChoiceField( choices=BEFORE_AFTER_CHOICE, required=False ), ) + if 'widget' not in kwargs: - kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES) + kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=possible_choices) forms.MultiValueField.__init__( self, fields=fields, require_all_fields=False, *args, **kwargs ) def set_event(self, event): - self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = [ - (k, v) for k, v in BASE_CHOICES if getattr(event, k, None) + choices = [ + (c.key, c.text) for c in EVENT_BASE_CHOICES if getattr(event, c.attribute, None) ] + if self.relative_to_order: + choices += [(c.key, c.text) for c in ORDER_BASE_CHOICES] + self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = choices def compress(self, data_list): if not data_list: return None data = reldateparts(*data_list) - if data.status == 'absolute': - return RelativeDateWrapper(data.absolute) - elif data.status == 'unset': + if data.status == 'unset': return None + elif data.status == 'absolute': + return RelativeDateWrapper(data.absolute) else: return RelativeDateWrapper(RelativeDate( days=data.rel_days_number, @@ -525,8 +737,14 @@ class RelativeDateField(RelativeDateTimeField): data = reldateparts(*value) if data.status == 'absolute' and not data.absolute: raise ValidationError(self.error_messages['incomplete']) - elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto): - raise ValidationError(self.error_messages['incomplete']) + if data.status == 'relative': + choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto) + if data.rel_days_number is None or not data.rel_days_relationto: + raise ValidationError(self.error_messages['incomplete']) + elif data.rel_days_relation == "before" and not choice.supports_before: + raise ValidationError(_("A relative date cannot be expressed as 'before' for '{}'".format(choice.text))) + elif data.rel_days_relation == "after" and not choice.supports_after: + raise ValidationError(_("A relative date cannot be expressed as 'after' for '{}'".format(choice.text))) return forms.MultiValueField.clean(self, value) diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index b8e004c33a..21e2320636 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -94,14 +94,14 @@ class SubEventBulkForm(SubEventForm): label=_('Start of presale'), help_text=_('Optional. No products will be sold before this date.'), required=False, - limit_choices=('date_from', 'date_to'), + limit_choices=('event__date_from', 'event__date_to'), ) rel_presale_end = RelativeDateTimeField( label=_('End of presale'), help_text=_('Optional. No products will be sold after this date. If you do not set this value, the presale ' 'will end after the end date of your event.'), required=False, - limit_choices=('date_from', 'date_to'), + limit_choices=('event__date_from', 'event__date_to'), ) skip_if_overlap = forms.BooleanField( label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'), @@ -333,12 +333,12 @@ class BulkSubEventItemForm(SubEventItemForm): rel_available_from = RelativeDateTimeField( label=_('Available from'), required=False, - limit_choices=('date_from', 'date_to'), + limit_choices=('event__date_from', 'event__date_to'), ) rel_available_until = RelativeDateTimeField( label=_('Available until'), required=False, - limit_choices=('date_from', 'date_to'), + limit_choices=('event__date_from', 'event__date_to'), ) def __init__(self, *args, **kwargs): @@ -355,12 +355,12 @@ class BulkSubEventItemVariationForm(SubEventItemVariationForm): rel_available_from = RelativeDateTimeField( label=_('Available from'), required=False, - limit_choices=('date_from', 'date_to'), + limit_choices=('event__date_from', 'event__date_to'), ) rel_available_until = RelativeDateTimeField( label=_('Available_until'), required=False, - limit_choices=('date_from', 'date_to'), + limit_choices=('event__date_from', 'event__date_to'), ) def __init__(self, *args, **kwargs): diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index bf9181d22c..e6b544be11 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -58,6 +58,7 @@ + diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index b40a415427..5cbf7fd310 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -431,7 +431,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): days = (self.copy_from.date_from.astimezone(tz).date() - value.astimezone(tz).date()).days return RelativeDateWrapper(RelativeDate( days=abs(days), - base_date_name='date_from', + base_date_name='event__date_from', time=value.astimezone(tz).time(), minutes=None, is_after=days < 0, diff --git a/src/pretix/static/pretixbase/js/reldate.js b/src/pretix/static/pretixbase/js/reldate.js new file mode 100644 index 0000000000..bc7defdfbc --- /dev/null +++ b/src/pretix/static/pretixbase/js/reldate.js @@ -0,0 +1,42 @@ +$(document).on('pretix:bind-forms', () => { + document.querySelectorAll('.reldatetime, .reldate').forEach(container => { + const groups = container.querySelectorAll('.radio') + + groups.forEach(group => { + const referenceSelect = group.querySelector('select[data-relative-choice]') + const beforeAfterSelect = group.querySelector('select[data-relation-choice]') + if (!referenceSelect || !beforeAfterSelect) return + + const beforeOption = beforeAfterSelect.querySelector('option[value="before"]') + const afterOption = beforeAfterSelect.querySelector('option[value="after"]') + + const updateBeforeOption = () => { + let supportsBefore = referenceSelect.selectedOptions[0].hasAttribute('data-supports-before') + if (beforeOption) { + beforeOption.disabled = !beforeOption.disabled && !supportsBefore + } + + let supportsAfter = referenceSelect.selectedOptions[0].hasAttribute('data-supports-after') + if (afterOption) { + afterOption.disabled = !afterOption.disabled && !supportsAfter + } + + let dirty = false + if (beforeOption.disabled && beforeAfterSelect.value === 'before') { + beforeAfterSelect.value = 'after' + dirty = true + } + if (afterOption.disabled && beforeAfterSelect.value === 'after') { + beforeAfterSelect.value = 'before' + dirty = true + } + + if (dirty) { + beforeAfterSelect.dispatchEvent(new Event('change', {bubbles: true})) + } + } + referenceSelect.addEventListener('change', updateBeforeOption) + updateBeforeOption() + }) + }) +}) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 6f2359db16..41d9d67ab3 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -473,7 +473,7 @@ class QuotaTestCase(BaseQuotaTestCase): @classscope(attr='o') def test_waitinglist_auto_disable(self): self.event.settings.waiting_list_auto_disable = RelativeDateWrapper( - RelativeDate(days=0, time=None, base_date_name='date_from', minutes=20, is_after=True) + RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=20, is_after=True) ) self.quota.items.add(self.item1) self.quota.size = 1 @@ -484,7 +484,7 @@ class QuotaTestCase(BaseQuotaTestCase): self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0)) self.assertEqual(self.item1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) self.event.settings.waiting_list_auto_disable = RelativeDateWrapper( - RelativeDate(days=0, time=None, base_date_name='date_from', minutes=20, is_after=False) + RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=20, is_after=False) ) self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) self.assertEqual(self.item1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) @@ -1192,7 +1192,7 @@ class OrderTestCase(BaseQuotaTestCase): self.op2.subevent = se2 self.op2.save() self.event.settings.set('payment_term_last', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) self.order.status = Order.STATUS_EXPIRED @@ -1347,7 +1347,7 @@ class OrderTestCase(BaseQuotaTestCase): self.op2.subevent = se2 self.op2.save() self.event.settings.set('last_order_modification_date', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) assert self.order.can_modify_answers self.op2.subevent = se3 @@ -1363,7 +1363,7 @@ class OrderTestCase(BaseQuotaTestCase): self.event.date_from = datetime.datetime(2017, 5, 3, 12, 0, 0, tzinfo=datetime.timezone.utc) self.event.save() self.event.settings.set('payment_term_last', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) assert self.order.payment_term_last == datetime.datetime(2017, 5, 1, 23, 59, 59, tzinfo=datetime.timezone.utc) @@ -1379,7 +1379,7 @@ class OrderTestCase(BaseQuotaTestCase): self.op2.subevent = se2 self.op2.save() self.event.settings.set('payment_term_last', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) assert self.order.payment_term_last > now() self.op2.subevent = se3 @@ -1395,7 +1395,7 @@ class OrderTestCase(BaseQuotaTestCase): self.event.date_from = datetime.datetime(2017, 5, 3, 12, 0, 0, tzinfo=datetime.timezone.utc) self.event.save() self.event.settings.set('ticket_download_date', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) assert self.order.ticket_download_date == datetime.datetime(2017, 5, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) @@ -1411,7 +1411,7 @@ class OrderTestCase(BaseQuotaTestCase): self.op2.subevent = se2 self.op2.save() self.event.settings.set('ticket_download_date', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) assert self.order.ticket_download_date > now() self.op2.subevent = se3 @@ -1582,13 +1582,13 @@ class OrderTestCase(BaseQuotaTestCase): assert self.order.user_cancel_deadline is None self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( - RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None) )) self.order = Order.objects.get(pk=self.order.pk) assert self.order.user_cancel_deadline > now() assert self.order.user_cancel_allowed self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( - RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None) + RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None) )) self.order = Order.objects.get(pk=self.order.pk) assert self.order.user_cancel_deadline < now() @@ -1607,7 +1607,7 @@ class OrderTestCase(BaseQuotaTestCase): self.op2.save() self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( - RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None) )) self.order = Order.objects.get(pk=self.order.pk) assert self.order.user_cancel_deadline < now() @@ -2018,13 +2018,13 @@ class OrderTestCase(BaseQuotaTestCase): assert self.order.user_change_deadline is None self.event.settings.set('change_allow_user_until', RelativeDateWrapper( - RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None) )) self.order = Order.objects.get(pk=self.order.pk) assert self.order.user_change_deadline > now() assert self.order.user_change_allowed self.event.settings.set('change_allow_user_until', RelativeDateWrapper( - RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None) + RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None) )) self.order = Order.objects.get(pk=self.order.pk) assert self.order.user_change_deadline < now() @@ -2046,7 +2046,7 @@ class OrderTestCase(BaseQuotaTestCase): self.op2.save() self.event.settings.set('change_allow_user_until', RelativeDateWrapper( - RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None) )) self.order = Order.objects.get(pk=self.order.pk) assert self.order.user_change_deadline < now() diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 821a4fc5f3..907a76dbc6 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -204,7 +204,7 @@ def test_expiry_last_relative(event): event.date_from = now() + timedelta(days=5) event.save() event.settings.set('payment_term_last', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) order = _create_order(event, email='dummy@example.org', positions=[], now_dt=today, @@ -245,7 +245,7 @@ def test_expiry_last_relative_subevents(event): ) event.settings.set('payment_term_last', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) order = _create_order(event, email='dummy@example.org', positions=[cp1, cp2], now_dt=today, diff --git a/src/tests/base/test_payment.py b/src/tests/base/test_payment.py index 9b88e37a79..a2823b9159 100644 --- a/src/tests/base/test_payment.py +++ b/src/tests/base/test_payment.py @@ -133,7 +133,7 @@ def test_availability_date_relative(event): event.save() prov = DummyPaymentProvider(event) prov.settings.set('_availability_date', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) utc = datetime.timezone.utc @@ -150,7 +150,7 @@ def test_availability_start_relative(event): event.save() prov = DummyPaymentProvider(event) prov.settings.set('_availability_start', RelativeDateWrapper( - RelativeDate(days=2, time=datetime.time(12, 0), base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=datetime.time(12, 0), base_date_name='event__date_from', minutes=None) )) utc = datetime.timezone.utc @@ -193,12 +193,12 @@ def test_availability_date_cart_relative_subevents(event): prov = DummyPaymentProvider(event) prov.settings.set('_availability_date', RelativeDateWrapper( - RelativeDate(days=3, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=3, time=None, base_date_name='event__date_from', minutes=None) )) assert prov._is_available_by_time(cart_id="123") prov.settings.set('_availability_date', RelativeDateWrapper( - RelativeDate(days=4, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=4, time=None, base_date_name='event__date_from', minutes=None) )) assert not prov._is_available_by_time(cart_id="123") @@ -233,11 +233,11 @@ def test_availability_date_order_relative_subevents(event): prov = DummyPaymentProvider(event) prov.settings.set('_availability_date', RelativeDateWrapper( - RelativeDate(days=3, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=3, time=None, base_date_name='event__date_from', minutes=None) )) assert prov._is_available_by_time(order=order) prov.settings.set('_availability_date', RelativeDateWrapper( - RelativeDate(days=4, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=4, time=None, base_date_name='event__date_from', minutes=None) )) assert not prov._is_available_by_time(order=order) diff --git a/src/tests/base/test_reldate.py b/src/tests/base/test_reldate.py index d5f0db9ec1..92cefb1a5b 100644 --- a/src/tests/base/test_reldate.py +++ b/src/tests/base/test_reldate.py @@ -19,13 +19,13 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from datetime import datetime, time +from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo import pytest from django_scopes import scope -from pretix.base.models import Event, Organizer +from pretix.base.models import Event, Order, Organizer from pretix.base.reldate import RelativeDate, RelativeDateWrapper TOKYO = ZoneInfo('Asia/Tokyo') @@ -40,7 +40,6 @@ def event(): date_from=datetime(2017, 12, 27, 5, 0, 0, tzinfo=TOKYO), presale_start=datetime(2017, 12, 1, 5, 0, 0, tzinfo=TOKYO), plugins='pretix.plugins.banktransfer' - ) event.settings.timezone = "Asia/Tokyo" return event @@ -56,57 +55,57 @@ def test_absolute_date(event): @pytest.mark.django_db def test_relative_date_without_time(event): - rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None)) + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None)) assert rdw.datetime(event).astimezone(TOKYO) == datetime(2017, 12, 26, 5, 0, 0, tzinfo=TOKYO) - assert rdw.to_string() == 'RELDATE/1/-/date_from/' - rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None, is_after=True)) + assert rdw.to_string() == 'RELDATE/1/-/event__date_from/' + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None, is_after=True)) assert rdw.datetime(event).astimezone(TOKYO) == datetime(2017, 12, 28, 5, 0, 0, tzinfo=TOKYO) - assert rdw.to_string() == 'RELDATE/1/-/date_from/after' + assert rdw.to_string() == 'RELDATE/1/-/event__date_from/after' @pytest.mark.django_db def test_relative_date_other_base_point(event): with scope(organizer=event.organizer): - rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_start', minutes=None)) + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_start', minutes=None)) assert rdw.datetime(event) == datetime(2017, 11, 30, 5, 0, 0, tzinfo=TOKYO) - assert rdw.to_string() == 'RELDATE/1/-/presale_start/' + assert rdw.to_string() == 'RELDATE/1/-/event__presale_start/' # presale_end is unset, defaults to date_from - rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_end', minutes=None)) + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_end', minutes=None)) assert rdw.datetime(event) == datetime(2017, 12, 26, 5, 0, 0, tzinfo=TOKYO) - assert rdw.to_string() == 'RELDATE/1/-/presale_end/' + assert rdw.to_string() == 'RELDATE/1/-/event__presale_end/' # subevent base se = event.subevents.create(name="SE1", date_from=datetime(2017, 11, 27, 5, 0, 0, tzinfo=TOKYO)) - rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None)) + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None)) assert rdw.datetime(se) == datetime(2017, 11, 26, 5, 0, 0, tzinfo=TOKYO) # presale_start is unset on subevent, default to event - rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_start', minutes=None)) + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_start', minutes=None)) assert rdw.datetime(se) == datetime(2017, 11, 30, 5, 0, 0, tzinfo=TOKYO) # presale_end is unset on all, default to date_from of subevent - rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_end', minutes=None)) + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_end', minutes=None)) assert rdw.datetime(se) == datetime(2017, 11, 26, 5, 0, 0, tzinfo=TOKYO) @pytest.mark.django_db def test_relative_date_in_minutes(event): - rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60)) - assert rdw.to_string() == 'RELDATE/minutes/60/date_from/' + rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=60)) + assert rdw.to_string() == 'RELDATE/minutes/60/event__date_from/' assert rdw.datetime(event) == datetime(2017, 12, 27, 4, 0, 0, tzinfo=TOKYO) - rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60, is_after=True)) - assert rdw.to_string() == 'RELDATE/minutes/60/date_from/after' + rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=60, is_after=True)) + assert rdw.to_string() == 'RELDATE/minutes/60/event__date_from/after' assert rdw.datetime(event) == datetime(2017, 12, 27, 6, 0, 0, tzinfo=TOKYO) @pytest.mark.django_db def test_relative_date_with_time(event): - rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='date_from', minutes=None)) - assert rdw.to_string() == 'RELDATE/1/08:05:13/date_from/' + rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='event__date_from', minutes=None)) + assert rdw.to_string() == 'RELDATE/1/08:05:13/event__date_from/' assert rdw.datetime(event) == datetime(2017, 12, 26, 8, 5, 13, tzinfo=TOKYO) - rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='date_from', minutes=None, is_after=True)) - assert rdw.to_string() == 'RELDATE/1/08:05:13/date_from/after' + rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='event__date_from', minutes=None, is_after=True)) + assert rdw.to_string() == 'RELDATE/1/08:05:13/event__date_from/after' assert rdw.datetime(event) == datetime(2017, 12, 28, 8, 5, 13, tzinfo=TOKYO) @@ -115,35 +114,94 @@ def test_relative_date_with_time_around_dst(event): event.settings.timezone = "Europe/Berlin" event.date_from = datetime(2020, 3, 29, 18, 0, 0, tzinfo=BERLIN) - rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='date_from', minutes=None)) - assert rdw.to_string() == 'RELDATE/1/18:00:00/date_from/' + rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='event__date_from', minutes=None)) + assert rdw.to_string() == 'RELDATE/1/18:00:00/event__date_from/' assert rdw.datetime(event) == datetime(2020, 3, 28, 18, 0, 0, tzinfo=BERLIN) - rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='date_from', minutes=None)) - assert rdw.to_string() == 'RELDATE/0/02:30:00/date_from/' + rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='event__date_from', minutes=None)) + assert rdw.to_string() == 'RELDATE/0/02:30:00/event__date_from/' assert rdw.datetime(event) == datetime(2020, 3, 29, 2, 30, 0, tzinfo=BERLIN) event.date_from = datetime(2020, 10, 25, 18, 0, 0, tzinfo=BERLIN) - rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='date_from', minutes=None)) - assert rdw.to_string() == 'RELDATE/1/18:00:00/date_from/' + rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='event__date_from', minutes=None)) + assert rdw.to_string() == 'RELDATE/1/18:00:00/event__date_from/' assert rdw.datetime(event) == datetime(2020, 10, 24, 18, 0, 0, tzinfo=BERLIN) - rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='date_from', minutes=None)) - assert rdw.to_string() == 'RELDATE/0/02:30:00/date_from/' + rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='event__date_from', minutes=None)) + assert rdw.to_string() == 'RELDATE/0/02:30:00/event__date_from/' assert rdw.datetime(event) == datetime(2020, 10, 25, 2, 30, 0, tzinfo=BERLIN) -def test_unserialize(): +def test_unserialize_backwards_compatibility(): + d = datetime(2017, 12, 25, 10, 0, 0, tzinfo=TOKYO) + rdw = RelativeDateWrapper.from_string(d.isoformat()) + assert rdw.data == d + + # keeping the test for the old from_string_format to ensure that we don't break anything + rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_from/') + assert rdw.data == RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None) + + # keeping the test for the old from_string_format to ensure that we don't break anything + rdw = RelativeDateWrapper.from_string('RELDATE/1/18:05:13/date_from/') + assert rdw.data == RelativeDate(days=1, time=time(18, 5, 13), base_date_name='date_from', minutes=None) + + # keeping the test for the old from_string_format to ensure that we don't break anything + rdw = RelativeDateWrapper.from_string('RELDATE/minutes/60/date_from/') + assert rdw.data == RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60) + + +def test_backwards_compatibility(): + # the data model of RelativeDate had to be extended to support other models as relation target + # previously only: + # - date_from + # - date_to + # - date_admission + # - presale_start + # - presale_end + # where valid values for the fourth slot (base_date_names) of the serialized form. + # the relationship in this case always pointed at event + # so any preexisting base_date_names without __ should continue to work and upgrade to event__{old_base_date_name} d = datetime(2017, 12, 25, 10, 0, 0, tzinfo=TOKYO) rdw = RelativeDateWrapper.from_string(d.isoformat()) assert rdw.data == d rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_from/') - assert rdw.data == RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None) + assert rdw.to_string() == 'RELDATE/1/-/event__date_from/' - rdw = RelativeDateWrapper.from_string('RELDATE/1/18:05:13/date_from/') - assert rdw.data == RelativeDate(days=1, time=time(18, 5, 13), base_date_name='date_from', minutes=None) + rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_to/') + assert rdw.to_string() == 'RELDATE/1/-/event__date_to/' - rdw = RelativeDateWrapper.from_string('RELDATE/minutes/60/date_from/') - assert rdw.data == RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60) + rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_admission/') + assert rdw.to_string() == 'RELDATE/1/-/event__date_admission/' + + rdw = RelativeDateWrapper.from_string('RELDATE/1/-/presale_start/') + assert rdw.to_string() == 'RELDATE/1/-/event__presale_start/' + + rdw = RelativeDateWrapper.from_string('RELDATE/1/-/presale_end/') + assert rdw.to_string() == 'RELDATE/1/-/event__presale_end/' + + # new order base_date_names should not work without __ + with pytest.raises(TypeError): + RelativeDateWrapper.from_string('RELDATE/1/-/datetime/') + with pytest.raises(TypeError): + RelativeDateWrapper.from_string('RELDATE/1/-/expires/') + + +@pytest.mark.django_db +def test_relative_to_order(event): + with scope(organizer=event.organizer): + order_moment = datetime(2020, 3, 29, 18, 0, 0, tzinfo=TOKYO) + + order = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1", + datetime=order_moment, + expires=order_moment + timedelta(days=10), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=23, locale='en' + ) + + rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='order__datetime', minutes=None, is_after=True)) + assert rdw.datetime(order).astimezone(TOKYO) == datetime(2020, 3, 30, 18, 0, 0, tzinfo=TOKYO) + assert rdw.to_string() == 'RELDATE/1/-/order__datetime/after' diff --git a/src/tests/base/test_waitinglist.py b/src/tests/base/test_waitinglist.py index df90eaa87d..ffa155d9e9 100644 --- a/src/tests/base/test_waitinglist.py +++ b/src/tests/base/test_waitinglist.py @@ -235,7 +235,7 @@ class WaitingListTestCase(TestCase): self.event.settings.set('waiting_list_enabled', True) self.event.settings.set('waiting_list_auto', True) self.event.settings.waiting_list_auto_disable = RelativeDateWrapper( - RelativeDate(days=0, time=None, base_date_name='date_from', minutes=20, is_after=False) + RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=20, is_after=False) ) self.event.save() with scope(organizer=self.o): diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index efd8a64d86..e363c48895 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -481,7 +481,7 @@ class EventsTest(SoupTest): 'payment_term_last_0': 'absolute', 'payment_term_last_1': (self.event1.presale_end - datetime.timedelta(1)).strftime('%Y-%m-%d'), 'payment_term_last_2': '0', - 'payment_term_last_3': 'date_from', + 'payment_term_last_3': 'event__date_from', 'tax_rule_payment': 'default', }) assert doc.select('.alert-danger') @@ -496,7 +496,7 @@ class EventsTest(SoupTest): 'payment_term_last_0': 'relative', 'payment_term_last_1': '', 'payment_term_last_2': '10', - 'payment_term_last_3': 'date_from', + 'payment_term_last_3': 'event__date_from', 'tax_rule_payment': 'default', }) assert doc.select('.alert-danger') diff --git a/src/tests/control/test_subevents.py b/src/tests/control/test_subevents.py index 1089e98912..532b1fa66c 100644 --- a/src/tests/control/test_subevents.py +++ b/src/tests/control/test_subevents.py @@ -230,19 +230,19 @@ class SubEventsTest(SoupTest): 'rel_presale_start_0': 'unset', 'rel_presale_start_1': '', 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', + 'rel_presale_start_3': 'event__date_from', 'rel_presale_start_4': '', 'rel_presale_start_5': '', - 'rel_presale_start_6': 'date_from', + 'rel_presale_start_6': 'event__date_from', 'rel_presale_start_7': 'before', 'rel_presale_start_8': 'before', 'rel_presale_end_1': '', 'rel_presale_end_0': 'relative', 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', + 'rel_presale_end_3': 'event__date_from', 'rel_presale_end_4': '13:29:31', 'rel_presale_end_5': '', - 'rel_presale_end_6': 'date_from', + 'rel_presale_end_6': 'event__date_from', 'rel_presale_end_7': 'before', 'rel_presale_end_8': 'before', 'quotas-TOTAL_FORMS': '1', @@ -328,19 +328,19 @@ class SubEventsTest(SoupTest): 'rel_presale_start_0': 'unset', 'rel_presale_start_1': '', 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', + 'rel_presale_start_3': 'event__date_from', 'rel_presale_start_4': '', 'rel_presale_start_5': '', - 'rel_presale_start_6': 'date_from', + 'rel_presale_start_6': 'event__date_from', 'rel_presale_start_7': 'before', 'rel_presale_start_8': 'before', 'rel_presale_end_1': '', 'rel_presale_end_0': 'relative', 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', + 'rel_presale_end_3': 'event__date_from', 'rel_presale_end_4': '13:29:31', 'rel_presale_end_5': '', - 'rel_presale_end_6': 'date_from', + 'rel_presale_end_6': 'event__date_from', 'rel_presale_end_7': 'before', 'rel_presale_end_8': 'before', 'quotas-TOTAL_FORMS': '1', @@ -403,19 +403,19 @@ class SubEventsTest(SoupTest): 'rel_presale_start_0': 'unset', 'rel_presale_start_1': '', 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', + 'rel_presale_start_3': 'event__date_from', 'rel_presale_start_4': '', 'rel_presale_start_5': '', - 'rel_presale_start_6': 'date_from', + 'rel_presale_start_6': 'event__date_from', 'rel_presale_start_7': 'before', 'rel_presale_start_8': 'before', 'rel_presale_end_1': '', 'rel_presale_end_0': 'relative', 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', + 'rel_presale_end_3': 'event__date_from', 'rel_presale_end_4': '13:29:31', 'rel_presale_end_5': '', - 'rel_presale_end_6': 'date_from', + 'rel_presale_end_6': 'event__date_from', 'rel_presale_end_7': 'before', 'rel_presale_end_8': 'before', 'quotas-TOTAL_FORMS': '1', @@ -479,19 +479,19 @@ class SubEventsTest(SoupTest): 'rel_presale_start_0': 'unset', 'rel_presale_start_1': '', 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', + 'rel_presale_start_3': 'event__date_from', 'rel_presale_start_4': '', 'rel_presale_start_5': '', - 'rel_presale_start_6': 'date_from', + 'rel_presale_start_6': 'event__date_from', 'rel_presale_start_7': 'before', 'rel_presale_start_8': 'before', 'rel_presale_end_1': '', 'rel_presale_end_0': 'relative', 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', + 'rel_presale_end_3': 'event__date_from', 'rel_presale_end_4': '02:30:00', 'rel_presale_end_5': '', - 'rel_presale_end_6': 'date_from', + 'rel_presale_end_6': 'event__date_from', 'rel_presale_end_7': 'before', 'rel_presale_end_8': 'before', 'quotas-TOTAL_FORMS': '1', @@ -567,19 +567,19 @@ class SubEventsTest(SoupTest): 'rel_presale_start_0': 'unset', 'rel_presale_start_1': '', 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', + 'rel_presale_start_3': 'event__date_from', 'rel_presale_start_4': '', 'rel_presale_start_5': '', - 'rel_presale_start_6': 'date_from', + 'rel_presale_start_6': 'event__date_from', 'rel_presale_start_7': 'before', 'rel_presale_start_8': 'before', 'rel_presale_end_1': '', 'rel_presale_end_0': 'relative', 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', + 'rel_presale_end_3': 'event__date_from', 'rel_presale_end_4': '13:29:31', 'rel_presale_end_5': '', - 'rel_presale_end_6': 'date_from', + 'rel_presale_end_6': 'event__date_from', 'rel_presale_end_7': 'before', 'rel_presale_end_8': 'before', 'quotas-TOTAL_FORMS': '1', @@ -639,19 +639,19 @@ class SubEventsTest(SoupTest): 'rel_presale_start_0': 'unset', 'rel_presale_start_1': '', 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', + 'rel_presale_start_3': 'event__date_from', 'rel_presale_start_4': '', 'rel_presale_start_5': '', - 'rel_presale_start_6': 'date_from', + 'rel_presale_start_6': 'event__date_from', 'rel_presale_start_7': 'before', 'rel_presale_start_8': 'before', 'rel_presale_end_0': 'unset', 'rel_presale_end_1': '', 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', + 'rel_presale_end_3': 'event__date_from', 'rel_presale_end_4': '13:29:31', 'rel_presale_end_5': '', - 'rel_presale_end_6': 'date_from', + 'rel_presale_end_6': 'event__date_from', 'rel_presale_end_7': 'before', 'rel_presale_end_8': 'before', 'quotas-TOTAL_FORMS': '1', @@ -711,19 +711,19 @@ class SubEventsTest(SoupTest): 'rel_presale_start_0': 'unset', 'rel_presale_start_1': '', 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', + 'rel_presale_start_3': 'event__date_from', 'rel_presale_start_4': '', 'rel_presale_start_5': '', - 'rel_presale_start_6': 'date_from', + 'rel_presale_start_6': 'event__date_from', 'rel_presale_start_7': 'before', 'rel_presale_start_8': 'before', 'rel_presale_end_0': 'unset', 'rel_presale_end_1': '', 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', + 'rel_presale_end_3': 'event__date_from', 'rel_presale_end_4': '13:29:31', 'rel_presale_end_5': '', - 'rel_presale_end_6': 'date_from', + 'rel_presale_end_6': 'event__date_from', 'rel_presale_end_7': 'before', 'rel_presale_end_8': 'before', 'quotas-TOTAL_FORMS': '1', diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 70dade2bed..053a5eff49 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -1132,7 +1132,7 @@ class WaitingListTest(EventTestMixin, SoupTest): def test_auto_disable(self): self.event.settings.set('waiting_list_enabled', True) self.event.settings.waiting_list_auto_disable = RelativeDateWrapper( - RelativeDate(days=900, time=datetime.time(9, 0, 0), base_date_name='date_from', minutes=None, is_after=False) + RelativeDate(days=900, time=datetime.time(9, 0, 0), base_date_name='event__date_from', minutes=None, is_after=False) ) response = self.client.get( '/%s/%s/' % (self.orga.slug, self.event.slug) diff --git a/src/tests/presale/test_order_change.py b/src/tests/presale/test_order_change.py index b53d6ce59b..6482625783 100644 --- a/src/tests/presale/test_order_change.py +++ b/src/tests/presale/test_order_change.py @@ -1527,7 +1527,7 @@ class OrderChangeAddonsTest(BaseOrdersTest): self.order.save() self._subevent_setup() self.event.settings.set('payment_term_last', RelativeDateWrapper( - RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None) + RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None) )) response = self.client.get( diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 9606938b80..72f4523d33 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -1076,7 +1076,7 @@ class OrdersTest(BaseOrdersTest): self.event.date_from = now() + datetime.timedelta(days=3) self.event.save() self.event.settings.set('ticket_download_date', RelativeDateWrapper(RelativeDate( - base_date_name='date_from', days=2, time=None, minutes=None + base_date_name='event__date_from', days=2, time=None, minutes=None ))) response = self.client.post( '/%s/%s/order/%s/%s/download/%d/testdummy' % (self.orga.slug, self.event.slug, self.order.code,