# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-today pretix 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 copy import datetime import os import warnings from collections import namedtuple 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 Promise, lazy from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from pretix.base.forms.widgets import OptionAttrsSelect 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: """ 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. """ def __init__(self, data: Union[datetime.datetime, RelativeDate]): self.data = data 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: return self.data.date(base) 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: return self.data.datetime(base) def to_string(self) -> str: if isinstance(self.data, (datetime.datetime, datetime.date)): return self.data.isoformat() else: return self.data.to_string() @classmethod def from_string(cls, input: str): if input.startswith('RELDATE/'): data = RelativeDate.from_string(input) else: data = parser.parse(input) return RelativeDateWrapper(data) def __len__(self): return len(self.to_string()) BEFORE_AFTER_CHOICE = ( ('before', _('before')), ('after', _('after')), ) reldatetimeparts = namedtuple('reldatetimeparts', ( "status", # 0 "absolute", # 1 "rel_days_number", # 2 "rel_mins_relationto", # 3 "rel_days_timeofday", # 4 "rel_mins_number", # 5 "rel_days_relationto", # 6 "rel_mins_relation", # 7 "rel_days_relation" # 8 )) 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 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] return now().replace( year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 ).strftime(df) def placeholder_time_format(): tf = get_format('TIME_INPUT_FORMATS')[0] return datetime.time(8, 30, 0).strftime(tf) 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=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=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): if isinstance(value, str): value = RelativeDateWrapper.from_string(value) if isinstance(value, reldatetimeparts): return value if not value: return reldatetimeparts( status="unset", absolute=None, rel_days_number=1, rel_mins_relationto="event__date_from", rel_days_timeofday=None, rel_mins_number=0, rel_days_relationto="event__date_from", rel_mins_relation="before", rel_days_relation="before" ) elif isinstance(value.data, (datetime.datetime, datetime.date)): return reldatetimeparts( status="absolute", absolute=value.data, rel_days_number=1, rel_mins_relationto="event__date_from", rel_days_timeofday=None, rel_mins_number=0, rel_days_relationto="event__date_from", rel_mins_relation="before", rel_days_relation="before" ) elif value.data.minutes is not None: return reldatetimeparts( status="relative_minutes", absolute=None, rel_days_number=None, rel_mins_relationto=value.data.key, rel_days_timeofday=None, rel_mins_number=value.data.minutes, 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" ) return reldatetimeparts( status="relative", absolute=None, rel_days_number=value.data.days, rel_mins_relationto=value.data.key, rel_days_timeofday=value.data.time, rel_mins_number=0, 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" ) def get_context(self, name, value, attrs): ctx = super().get_context(name, value, attrs) ctx['required'] = self.status_choices[0][0] == 'unset' ctx['rendered_subwidgets'] = self.parts(*( self._render(w['template_name'], {**ctx, 'widget': w}) for w in ctx['widget']['subwidgets'] ))._asdict() return ctx class RelativeDateTimeField(forms.MultiValueField): def __init__(self, *args, **kwargs): status_choices = [ ('absolute', _('Fixed date:')), ('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') 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, ), absolute=forms.DateTimeField( required=False ), rel_days_number=forms.IntegerField( required=False ), rel_mins_relationto=forms.ChoiceField( choices=choices, required=False, ), rel_days_timeofday=forms.TimeField( required=False, ), rel_mins_number=forms.IntegerField( required=False ), rel_days_relationto=forms.ChoiceField( choices=choices, required=False, ), rel_mins_relation=forms.ChoiceField( choices=BEFORE_AFTER_CHOICE, required=False ), rel_days_relation=forms.ChoiceField( choices=BEFORE_AFTER_CHOICE, required=False ), ) if 'widget' not in kwargs: kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=possible_choices) kwargs.pop('max_length', 0) kwargs.pop('empty_value', 0) super().__init__( fields=fields, require_all_fields=False, *args, **kwargs ) def set_event(self, event): 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 == 'unset': return None elif data.status == 'absolute': return RelativeDateWrapper(data.absolute) elif data.status == 'relative_minutes': return RelativeDateWrapper(RelativeDate( days=0, base_date_name=data.rel_mins_relationto, time=None, minutes=data.rel_mins_number, is_after=data.rel_mins_relation == "after", )) else: return RelativeDateWrapper(RelativeDate( days=data.rel_days_number, base_date_name=data.rel_days_relationto, time=data.rel_days_timeofday, minutes=None, is_after=data.rel_days_relation == "after", )) 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): data = reldatetimeparts(*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']) 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) reldateparts = namedtuple('reldateparts', ( "status", # 0 "absolute", # 1 "rel_days_number", # 2 "rel_days_relationto", # 3 "rel_days_relation", # 4 )) reldateparts.indizes = reldateparts(*range(5)) class RelativeDateWidget(RelativeDateTimeWidget): template_name = 'pretixbase/forms/widgets/reldate.html' parts = reldateparts def __init__(self, *args, **kwargs): self.status_choices = kwargs.pop('status_choices') 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=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): if isinstance(value, str): value = RelativeDateWrapper.from_string(value) if not value: return reldateparts( status="unset", absolute=None, rel_days_number=1, rel_days_relationto="date_from", rel_days_relation="before" ) if isinstance(value, reldateparts): return value elif isinstance(value.data, (datetime.datetime, datetime.date)): return reldateparts( status="absolute", absolute=value.data, rel_days_number=1, rel_days_relationto="date_from", rel_days_relation="before" ) return reldateparts( status="relative", absolute=None, rel_days_number=value.data.days, rel_days_relationto=value.data.key, rel_days_relation="after" if value.data.is_after else "before" ) class RelativeDateField(RelativeDateTimeField): def __init__(self, *args, **kwargs): status_choices = [ ('absolute', _('Fixed date:')), ('relative', _('Relative date:')), ] 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, required=True ), absolute=forms.DateField( required=False ), rel_days_number=forms.IntegerField( required=False ), rel_days_relationto=forms.ChoiceField( 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=possible_choices) forms.MultiValueField.__init__( self, fields=fields, require_all_fields=False, *args, **kwargs ) def set_event(self, event): 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 == 'unset': return None elif data.status == 'absolute': return RelativeDateWrapper(data.absolute) else: return RelativeDateWrapper(RelativeDate( days=data.rel_days_number, base_date_name=data.rel_days_relationto, time=None, minutes=None, is_after=data.rel_days_relation == "after" )) def clean(self, value): data = reldateparts(*value) if data.status == 'absolute' and not data.absolute: 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) class ModelRelativeDateTimeField(models.CharField): form_class = RelativeDateTimeField def __init__(self, *args, **kwargs): self.event = kwargs.pop('event', None) kwargs.setdefault('max_length', 255) super().__init__(*args, **kwargs) def to_python(self, value): if isinstance(value, RelativeDateWrapper): return value if value is None: return None return RelativeDateWrapper.from_string(value) def get_prep_value(self, value): if isinstance(value, RelativeDateWrapper): return value.to_string() return value def from_db_value(self, value, expression, connection): if value is None: return None return RelativeDateWrapper.from_string(value) def formfield(self, **kwargs): defaults = {'form_class': self.form_class} defaults.update(kwargs) return super().formfield(**defaults) class SerializerRelativeDateField(serializers.CharField): def to_internal_value(self, data): if data is None: return None try: r = RelativeDateWrapper.from_string(data) if isinstance(r.data, RelativeDate): if r.data.time is not None: raise ValidationError("Do not specify a time for a date field") return r except: raise ValidationError("Invalid relative date") def to_representation(self, value: RelativeDateWrapper): if value is None: return None return value.to_string() class SerializerRelativeDateTimeField(serializers.CharField): def to_internal_value(self, data): if data is None: return None try: return RelativeDateWrapper.from_string(data) except: raise ValidationError("Invalid relative date") def to_representation(self, value: RelativeDateWrapper): if value is None: return None return value.to_string()