diff --git a/src/pretix/base/reldate.py b/src/pretix/base/reldate.py index b868bb670b..a47e01ae44 100644 --- a/src/pretix/base/reldate.py +++ b/src/pretix/base/reldate.py @@ -19,10 +19,12 @@ # 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, Tuple, Union, ) @@ -41,16 +43,21 @@ from rest_framework import serializers if TYPE_CHECKING: from .models import Event, Order, SubEvent - +@dataclass(frozen=True) class BaseChoice: - def __init__(self, base: Literal["event", "order"], attribute: str, text: Promise, supports_before: bool, - supports_after: bool) -> None: - self.base = base - self.attribute = attribute - self.text = text - self.supports_before = supports_before - self.supports_after = supports_after - self.key = f"{self.base}__{self.attribute}" + base: Literal["event", "order", "order.subevents"] + attribute: str + modifier: str + text: Promise + supports_before: bool + supports_after: bool + + @property + def key(self) -> str: + key = f"{self.base}__{self.attribute}" + if self.modifier: + key += f"__{self.modifier}" + return key @staticmethod def find(objects: Iterable["BaseChoice"], key: str) -> "BaseChoice": @@ -67,13 +74,19 @@ class BaseChoice: 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), + 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), + BaseChoice('order.subevents', 'date_from', "first", _('Subevent start (first subevent in order)'), True, True), + BaseChoice('order.subevents', 'date_from', "last", _('Subevent start (last subevent in order)'), True, True), + BaseChoice('order.subevents', 'date_to', "first", _('Subevent end (first subevent in order)'), True, True), + BaseChoice('order.subevents', 'date_to', "last", _('Subevent end (last subevent in order)'), True, True), + BaseChoice('order.subevents', 'date_admission', "first", _('Subevent admission (first subevent in order)'), True, True), + BaseChoice('order.subevents', 'date_admission', "last", _('Subevent admission (last subevent in order)'), True, True), ] LIMIT_FALLBACKS = ['date_from', 'date_to', 'date_admission', 'presale_start', 'presale_end'] @@ -86,7 +99,11 @@ ORDER_BASE_CHOICES = [ x for x in BASE_CHOICES if x.base == 'order' ] +SUBEVENT_BASE_CHOICES = [ + x for x in BASE_CHOICES if x.base == 'order.subevents' +] +@dataclass(frozen=True) class RelativeDate: """ This contains information on a date that is defined in relation to a fixed base point. @@ -97,25 +114,28 @@ class RelativeDate: If the base_date_key is not set, the date_from attribute of Event is used. """ - def __init__(self, days: int = 0, minutes: int = None, time: datetime.time = None, is_after: bool = False, - base_date_name: str = 'event__date_from') -> None: - choice = BaseChoice.find(BASE_CHOICES, base_date_name) - self.base = choice.base - self.attribute = choice.attribute + days: int = 0 + minutes: int = None + time: datetime.time = None + is_after: bool = False + base_date_name: str = 'event__date_from__' - if is_after and not choice.supports_after: + 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 is_after and not choice.supports_before: + 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") - self.is_after = is_after - self.days = days - self.minutes = minutes - self.time = time - self.key = choice.key + @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): @@ -130,17 +150,35 @@ class RelativeDate: """ from .models import Event, Order, SubEvent - if self.base == "order" and isinstance(base, Order): + choice = self._choice + + if choice.base == "order" and isinstance(base, Order): event = base.event - base_date = getattr(base, self.attribute) - elif self.base == "event" and isinstance(base, SubEvent): + base_date = getattr(base, choice.attribute) + elif choice.base == "order.subevent" and isinstance(base, Order): + if not base.event.has_subevents: + raise ValueError("The order is for an event without subevents") + if choice.modifier == "first": + op = base.all_positions.order_by(f"subevent__{choice.attribute}").first() + if op is None: + raise ValueError("The order has no positions for subevents") + event = op.event + base_date = getattr(base, choice.attribute) + elif choice.modifier == "last": + op = base.all_positions.order_by(f"subevent__{choice.attribute}").last() + if op is None: + raise ValueError("The order has no positions for subevents") + base_date = getattr(base, choice.attribute) + else: + raise ValueError("The selected modifier does not exist") + elif choice.base == "event" and isinstance(base, SubEvent): event = base.event base_date = (getattr(base, self.attribute) or getattr(base.event, self.attribute) or base.date_from) - elif self.base == "event" and isinstance(base, Event): + elif choice.base == "event" and isinstance(base, Event): event = base - base_date = getattr(base, self.attribute) or event.date_from + 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") @@ -189,13 +227,13 @@ class RelativeDate: if self.minutes is not None: return 'RELDATE/minutes/{}/{}/{}'.format( # self.minutes, - self.key, + 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.key, + self._choice.key, 'after' if self.is_after else '', ) @@ -304,13 +342,20 @@ reldatetimeparts = namedtuple('reldatetimeparts', ( reldatetimeparts.indizes = reldatetimeparts(*range(9)) +def _get_choices(choices: List[BaseChoice])->List[Tuple[str, Promise]]: + return [(c.key, c.text) for c in 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') + self.base_choices = kwargs.pop('base_choices') def placeholder_datetime_format(): df = get_format('DATETIME_INPUT_FORMATS')[0] @@ -328,14 +373,14 @@ class RelativeDateTimeWidget(forms.MultiWidget): 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=self.base_choices, option_attrs=_get_choice_validation_obj(self.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=self.base_choices, option_attrs=_get_choice_validation_obj(self.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) @@ -401,6 +446,8 @@ class RelativeDateTimeWidget(forms.MultiWidget): for w in ctx['widget']['subwidgets'] ))._asdict() + ctx['choice_validation'] = _get_choice_validation_obj(self.base_choices) + ctx['choice_validation_name'] = f'{name}_val' return ctx @@ -412,31 +459,34 @@ class RelativeDateTimeField(forms.MultiValueField): ('relative_minutes', _('Relative time:')), ] self.relative_to_order = kwargs.pop('relative_to_order', False) + self.relative_to_subevent_positions = kwargs.pop('relative_to_subevent_positions', False) - all_choices = EVENT_BASE_CHOICES + possible_choices = copy.deepcopy(EVENT_BASE_CHOICES) if self.relative_to_order: - all_choices.extend(ORDER_BASE_CHOICES) + possible_choices.extend(ORDER_BASE_CHOICES) + if self.relative_to_subevent_positions: + possible_choices.extend(SUBEVENT_BASE_CHOICES) if kwargs.get('limit_choices'): limit = kwargs.pop('limit_choices') - if any("__" not in l for l in limit): + if any(["__" not in l for l in limit]): _warn_skips = (os.path.dirname(__file__),) warnings.warn( "Please prefix limit_choices with the base the attributes refer to, for example event__date_from", skip_file_prefixes=_warn_skips) - choices = [(c.key, c.text) for c in all_choices if + 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)] - else: - choices = [(c.key, c.text) for c in all_choices] 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, @@ -471,8 +521,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__( @@ -480,11 +531,15 @@ class RelativeDateTimeField(forms.MultiValueField): ) def set_event(self, event): - choices = [ - (c.key, c.text) for c in EVENT_BASE_CHOICES if getattr(event, c.attribute, None) - ] + possible_choices = copy.deepcopy(EVENT_BASE_CHOICES) if self.relative_to_order: - choices += [(c.key, c.text) for c in ORDER_BASE_CHOICES] + possible_choices.extend(ORDER_BASE_CHOICES) + if self.relative_to_subevent_positions and event.has_subevents: + possible_choices.extend(SUBEVENT_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 @@ -529,15 +584,15 @@ class RelativeDateTimeField(forms.MultiValueField): 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))) + 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))) + 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))) + 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))) + raise ValidationError(_('A relative time cannot be expressed as "after" for "{}"'.format(choice.text))) return super().clean(value) @@ -558,15 +613,20 @@ class RelativeDateWidget(RelativeDateTimeWidget): def __init__(self, *args, **kwargs): self.status_choices = kwargs.pop('status_choices') - base_choices = kwargs.pop('base_choices') + self.base_choices = kwargs.pop('base_choices') + widgets = 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=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) @@ -599,6 +659,13 @@ class RelativeDateWidget(RelativeDateTimeWidget): 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['choice_validation'] = _get_choice_validation_obj(self.base_choices) + ctx['choice_validation_name'] = f'{name}_val' + return ctx + class RelativeDateField(RelativeDateTimeField): diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 41fc60f8de..490cad53f7 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -57,6 +57,7 @@ + diff --git a/src/pretix/presale/templates/pretixpresale/fragment_js.html b/src/pretix/presale/templates/pretixpresale/fragment_js.html index df7a598dee..37ca39ed1f 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_js.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_js.html @@ -22,5 +22,4 @@ - {% endcompress %}