From 22f91f7aa2ddf11b2fce3921fde7ee8e98dc40de Mon Sep 17 00:00:00 2001 From: Mira Date: Tue, 6 Feb 2024 12:27:19 +0100 Subject: [PATCH] Improve UI to configure unavailable items handling (Z#23131828) (#3739) * start impl of unavailability modes ui * add db migration * use new widget for more fields * improve contrast * use new widget for hide_without_voucher field * improved wording * rebase migration * undo changes to require_membership_hidden * code formatting * move unavail_reason logic around * enforce consistent state of hide_without_voucher / require_voucher * annotate unavailability info in get_grouped_items * remove MSIE6 compat * add unavailability reasons to widget * remove test output * Apply suggestions from code review text improvements Co-authored-by: Richard Schreiber * add css fix for jumping items due to tooltip * dynamically retrieve unavailability reason message * widget: simplify logic conditions * add available_{from,until}_mode to api and api docs * rebase migration * rebase migration * add unavailable_*_mode to ItemVariation * add available_*_mode to API docs for items * fix wrong reference * fix test cases * add available_*_mode to item variation form * apply unavailability modes to subevents and variations (presale) * /o\ * apply unavailability modes to subevents and variations (widget) * display unavailability mode in subevent product settings * fix widget test * fix api item tests * copy available_*_mode when copying an item * Apply suggestions from code review Co-authored-by: Raphael Michel * Add unavail mode indicator to bulk create and edit forms --------- Co-authored-by: Richard Schreiber Co-authored-by: Raphael Michel --- doc/api/resources/item_variations.rst | 20 ++++++ doc/api/resources/items.rst | 46 +++++++++++++ src/pretix/api/serializers/item.py | 9 ++- .../migrations/0255_item_unavail_modes.py | 22 ++++++ .../0256_itemvariation_unavail_modes.py | 22 ++++++ src/pretix/base/models/items.py | 67 ++++++++++++++++++- src/pretix/control/forms/__init__.py | 14 ++++ src/pretix/control/forms/item.py | 57 +++++++++++++++- src/pretix/control/forms/renderers.py | 16 +++++ src/pretix/control/forms/subevents.py | 4 ++ .../pretixcontrol/button_group_radio.html | 6 ++ .../button_group_radio_option.html | 2 + .../item/include_variations.html | 4 +- .../templates/pretixcontrol/item/index.html | 29 ++++---- .../pretixcontrol/subevents/bulk.html | 6 +- .../pretixcontrol/subevents/bulk_edit.html | 6 +- .../pretixcontrol/subevents/detail.html | 8 ++- .../fragment_unavail_mode_indicator.html | 6 ++ src/pretix/control/views/item.py | 2 + .../event/fragment_availability.html | 15 ++++- .../event/fragment_product_list.html | 12 +--- .../pretixpresale/event/voucher.html | 8 +-- src/pretix/presale/views/event.py | 17 +++-- src/pretix/presale/views/widget.py | 3 +- src/pretix/settings.py | 1 + src/pretix/static/pretixbase/scss/_theme.scss | 10 +++ .../static/pretixcontrol/scss/main.scss | 4 ++ .../static/pretixpresale/js/widget/widget.js | 41 ++++++------ src/tests/api/test_items.py | 8 +++ src/tests/presale/test_widget.py | 24 ++++--- 30 files changed, 410 insertions(+), 79 deletions(-) create mode 100644 src/pretix/base/migrations/0255_item_unavail_modes.py create mode 100644 src/pretix/base/migrations/0256_itemvariation_unavail_modes.py create mode 100644 src/pretix/control/templates/pretixcontrol/button_group_radio.html create mode 100644 src/pretix/control/templates/pretixcontrol/button_group_radio_option.html create mode 100644 src/pretix/control/templates/pretixcontrol/subevents/fragment_unavail_mode_indicator.html diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index ff0f12ffb..22e9df940 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -45,8 +45,16 @@ sales_channels list of strings Sales channels available. available_from datetime The first date time at which this variation can be bought (or ``null``). +available_from_mode string If ``hide`` (the default), this variation is hidden in the shop + if unavailable due to the available_from setting. + If ``info``, the variation is visible, but can't be purchased, + and a note explaining the unavailability is displayed. available_until datetime The last date time at which this variation can be bought (or ``null``). +available_until_mode string If ``hide`` (the default), this variation is hidden in the shop + if unavailable due to the available_until setting. + If ``info``, the variation is visible, but can't be purchased, + and a note explaining the unavailability is displayed. hide_without_voucher boolean If ``true``, this variation is only shown during the voucher redemption process, but not in the normal shop frontend. @@ -105,7 +113,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": { "en": "Test2" @@ -131,7 +141,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": {}, "position": 1, @@ -192,7 +204,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "position": 0, @@ -232,7 +246,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "position": 0, @@ -263,7 +279,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "position": 0, @@ -325,7 +343,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "position": 1, diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 1ac0c39e7..c48a5c64c 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -50,8 +50,16 @@ sales_channels list of strings Sales channel ``"web"`` or ``"resellers"``. Defaults to ``["web"]``. available_from datetime The first date time at which this item can be bought (or ``null``). +available_from_mode string If ``hide`` (the default), this item is hidden in the shop + if unavailable due to the ``available_from`` setting. + If ``info``, the item is visible, but can't be purchased, + and a note explaining the unavailability is displayed. available_until datetime The last date time at which this item can be bought (or ``null``). +available_until_mode string If ``hide`` (the default), this item is hidden in the shop + if unavailable due to the ``available_until`` setting. + If ``info``, the item is visible, but can't be purchased, + and a note explaining the unavailability is displayed. hidden_if_available integer **DEPRECATED** The internal ID of a quota object, or ``null``. If set, this item won't be shown publicly as long as this quota is available. @@ -156,8 +164,16 @@ variations list of objects A list with o available. ├ available_from datetime The first date time at which this variation can be bought (or ``null``). +├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop + if unavailable due to the ``available_from`` setting. + If ``info``, the variation is visible, but can't be purchased, + and a note explaining the unavailability is displayed. ├ available_until datetime The last date time at which this variation can be bought (or ``null``). +├ available_until_mode string If ``hide`` (the default), this variation is hidden in the shop + if unavailable due to the ``available_until`` setting. + If ``info``, the variation is visible, but can't be purchased, + and a note explaining the unavailability is displayed. ├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher redemption process, but not in the normal shop frontend. @@ -279,7 +295,9 @@ Endpoints "position": 0, "picture": null, "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hidden_if_available": null, "hidden_if_item_available": null, "require_voucher": false, @@ -324,7 +342,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -344,7 +364,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -417,7 +439,9 @@ Endpoints "position": 0, "picture": null, "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hidden_if_available": null, "hidden_if_item_available": null, "require_voucher": false, @@ -463,7 +487,9 @@ Endpoints "description": null, "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "meta_data": {}, "position": 0 @@ -482,7 +508,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -536,7 +564,9 @@ Endpoints "position": 0, "picture": null, "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hidden_if_available": null, "hidden_if_item_available": null, "require_voucher": false, @@ -580,7 +610,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -600,7 +632,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -642,7 +676,9 @@ Endpoints "position": 0, "picture": null, "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hidden_if_available": null, "hidden_if_item_available": null, "require_voucher": false, @@ -687,7 +723,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -707,7 +745,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -780,7 +820,9 @@ Endpoints "position": 0, "picture": null, "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hidden_if_available": null, "hidden_if_item_available": null, "require_voucher": false, @@ -825,7 +867,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, @@ -845,7 +889,9 @@ Endpoints "require_membership_types": [], "sales_channels": ["web"], "available_from": null, + "available_from_mode": "hide", "available_until": null, + "available_until_mode": "hide", "hide_without_voucher": false, "description": null, "meta_data": {}, diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 7fcdac087..31c217ace 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -61,7 +61,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): fields = ('id', 'value', 'active', 'description', 'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval', 'require_membership', 'require_membership_types', 'require_membership_hidden', - 'checkin_attention', 'checkin_text', 'available_from', 'available_until', + 'checkin_attention', 'checkin_text', + 'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'sales_channels', 'hide_without_voucher', 'meta_data') def __init__(self, *args, **kwargs): @@ -85,7 +86,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer): fields = ('id', 'value', 'active', 'description', 'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval', 'require_membership', 'require_membership_types', 'require_membership_hidden', - 'checkin_attention', 'checkin_text', 'available_from', 'available_until', + 'checkin_attention', 'checkin_text', + 'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'sales_channels', 'hide_without_voucher', 'meta_data') def __init__(self, *args, **kwargs): @@ -235,7 +237,8 @@ class ItemSerializer(I18nAwareModelSerializer): model = Item fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', 'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission', - 'personalized', 'position', 'picture', 'available_from', 'available_until', + 'personalized', 'position', 'picture', + 'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', diff --git a/src/pretix/base/migrations/0255_item_unavail_modes.py b/src/pretix/base/migrations/0255_item_unavail_modes.py new file mode 100644 index 000000000..dd63e8c2f --- /dev/null +++ b/src/pretix/base/migrations/0255_item_unavail_modes.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.4 on 2023-11-22 20:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pretixbase", "0254_alter_logentry_organizer_link_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="item", + name="available_from_mode", + field=models.CharField(default="hide", max_length=16), + ), + migrations.AddField( + model_name="item", + name="available_until_mode", + field=models.CharField(default="hide", max_length=16), + ) + ] diff --git a/src/pretix/base/migrations/0256_itemvariation_unavail_modes.py b/src/pretix/base/migrations/0256_itemvariation_unavail_modes.py new file mode 100644 index 000000000..33e0d7dac --- /dev/null +++ b/src/pretix/base/migrations/0256_itemvariation_unavail_modes.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.4 on 2024-01-11 15:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pretixbase", "0255_item_unavail_modes"), + ] + + operations = [ + migrations.AddField( + model_name="itemvariation", + name="available_from_mode", + field=models.CharField(default="hide", max_length=16), + ), + migrations.AddField( + model_name="itemvariation", + name="available_until_mode", + field=models.CharField(default="hide", max_length=16), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 780af9c73..f518a3ec2 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -263,8 +263,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False): # IMPORTANT: If this is updated, also update the ItemVariation query # in models/event.py: EventMixin.annotated() Q(active=True) - & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) - & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) + & Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_mode='info')) + & Q(Q(available_until__isnull=True) | Q(available_until__gte=now()) | Q(available_until_mode='info')) & Q(sales_channels__contains=channel) & Q(require_bundling=False) ) if not allow_addons: @@ -374,6 +374,13 @@ class Item(LoggedModel): (VALIDITY_MODE_DYNAMIC, _('Dynamic validity')), ) + UNAVAIL_MODE_HIDDEN = "hide" + UNAVAIL_MODE_INFO = "info" + UNAVAIL_MODES = ( + (UNAVAIL_MODE_HIDDEN, _("Hide product if unavailable")), + (UNAVAIL_MODE_INFO, _("Show info text if unavailable")), + ) + MEDIA_POLICY_REUSE = 'reuse' MEDIA_POLICY_NEW = 'new' MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new' @@ -487,11 +494,21 @@ class Item(LoggedModel): null=True, blank=True, help_text=_('This product will not be sold before the given date.') ) + available_from_mode = models.CharField( + choices=UNAVAIL_MODES, + default=UNAVAIL_MODE_HIDDEN, + max_length=16, + ) available_until = models.DateTimeField( verbose_name=_("Available until"), null=True, blank=True, help_text=_('This product will not be sold after the given date.') ) + available_until_mode = models.CharField( + choices=UNAVAIL_MODES, + default=UNAVAIL_MODE_HIDDEN, + max_length=16, + ) hidden_if_available = models.ForeignKey( 'Quota', null=True, blank=True, @@ -703,6 +720,8 @@ class Item(LoggedModel): return str(self.internal_name or self.name) def save(self, *args, **kwargs): + if self.hide_without_voucher: + self.require_voucher = True super().save(*args, **kwargs) if self.event: self.event.cache.clear() @@ -780,6 +799,24 @@ class Item(LoggedModel): return False return True + def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: + now_dt = now_dt or now() + subevent_item = subevent and subevent.item_overrides.get(self.pk) + if not self.active: + return 'active' + elif self.available_from and self.available_from > now_dt: + return 'available_from' + elif self.available_until and self.available_until < now_dt: + return 'available_until' + elif (self.require_voucher or self.hide_without_voucher) and not has_voucher: + return 'require_voucher' + elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt: + return 'available_from' + elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt: + return 'available_until' + else: + return None + def _get_quotas(self, ignored_quotas=None, subevent=None): check_quotas = set(getattr( self, '_subevent_quotas', # Utilize cache in product list @@ -1078,11 +1115,21 @@ class ItemVariation(models.Model): null=True, blank=True, help_text=_('This variation will not be sold before the given date.') ) + available_from_mode = models.CharField( + choices=Item.UNAVAIL_MODES, + default=Item.UNAVAIL_MODE_HIDDEN, + max_length=16, + ) available_until = models.DateTimeField( verbose_name=_("Available until"), null=True, blank=True, help_text=_('This variation will not be sold after the given date.') ) + available_until_mode = models.CharField( + choices=Item.UNAVAIL_MODES, + default=Item.UNAVAIL_MODE_HIDDEN, + max_length=16, + ) sales_channels = fields.MultiStringField( verbose_name=_('Sales channels'), default=_all_sales_channels_identifiers, @@ -1260,6 +1307,22 @@ class ItemVariation(models.Model): return False return True + def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: + now_dt = now_dt or now() + subevent_var = subevent and subevent.var_overrides.get(self.pk) + if not self.active: + return 'active' + elif self.available_from and self.available_from > now_dt: + return 'available_from' + elif self.available_until and self.available_until < now_dt: + return 'available_until' + elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt: + return 'available_from' + elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt: + return 'available_until' + else: + return None + @property def meta_data(self): data = self.item.meta_data diff --git a/src/pretix/control/forms/__init__.py b/src/pretix/control/forms/__init__.py index 9488517cd..67bc6f461 100644 --- a/src/pretix/control/forms/__init__.py +++ b/src/pretix/control/forms/__init__.py @@ -415,3 +415,17 @@ class FontSelect(forms.RadioSelect): class ItemMultipleChoiceField(SafeModelMultipleChoiceField): def label_from_instance(self, obj): return str(obj) if obj.active else mark_safe(f'{escape(obj)}') + + +class ButtonGroupRadioSelect(forms.RadioSelect): + template_name = 'pretixcontrol/button_group_radio.html' + option_template_name = 'pretixcontrol/button_group_radio_option.html' + + def __init__(self, *args, **kwargs): + self.option_icons = kwargs.pop('option_icons') + super().__init__(*args, **kwargs) + + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + attrs['icon'] = self.option_icons[value] + opt = super().create_option(name, value, label, selected, index, subindex, attrs) + return opt diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index ac1c97d54..071a5c347 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -64,8 +64,8 @@ from pretix.base.models import ( from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.signals import item_copy_data from pretix.control.forms import ( - ItemMultipleChoiceField, SizeValidationMixin, SplitDateTimeField, - SplitDateTimePickerWidget, + ButtonGroupRadioSelect, ItemMultipleChoiceField, SizeValidationMixin, + SplitDateTimeField, SplitDateTimePickerWidget, ) from pretix.control.forms.widgets import Select2 from pretix.helpers.models import modelcopy @@ -380,7 +380,9 @@ class ItemCreateForm(I18nModelForm): 'description', 'active', 'available_from', + 'available_from_mode', 'available_until', + 'available_until_mode', 'require_voucher', 'hide_without_voucher', 'allow_cancel', @@ -562,6 +564,34 @@ class ItemUpdateForm(I18nModelForm): ) change_decimal_field(self.fields['default_price'], self.event.currency) + self.fields['available_from_mode'].widget = ButtonGroupRadioSelect( + choices=self.fields['available_from_mode'].choices, + option_icons={ + Item.UNAVAIL_MODE_HIDDEN: 'eye-slash', + Item.UNAVAIL_MODE_INFO: 'info' + } + ) + + self.fields['available_until_mode'].widget = ButtonGroupRadioSelect( + choices=self.fields['available_until_mode'].choices, + option_icons={ + Item.UNAVAIL_MODE_HIDDEN: 'eye-slash', + Item.UNAVAIL_MODE_INFO: 'info' + } + ) + + self.fields['hide_without_voucher'].widget = ButtonGroupRadioSelect( + choices=( + (True, _("Hide product if unavailable")), + (False, _("Show product with info on why it’s unavailable")), + ), + option_icons={ + True: 'eye-slash', + False: 'info' + }, + attrs={'data-checkbox-dependency': '#id_require_voucher'} + ) + if self.instance.hidden_if_available_id: self.fields['hidden_if_available'].queryset = self.event.quotas.all() self.fields['hidden_if_available'].help_text = format_html( @@ -656,6 +686,9 @@ class ItemUpdateForm(I18nModelForm): ) ) + if not d.get('require_voucher'): + d['hide_without_voucher'] = False + if d.get('require_membership') and not d.get('require_membership_types'): self.add_error( 'require_membership_types', @@ -713,7 +746,9 @@ class ItemUpdateForm(I18nModelForm): 'free_price_suggestion', 'tax_rule', 'available_from', + 'available_from_mode', 'available_until', + 'available_until_mode', 'require_voucher', 'require_approval', 'hide_without_voucher', @@ -850,6 +885,22 @@ class ItemVariationForm(I18nModelForm): self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price' + self.fields['available_from_mode'].widget = ButtonGroupRadioSelect( + choices=self.fields['available_from_mode'].choices, + option_icons={ + Item.UNAVAIL_MODE_HIDDEN: 'eye-slash', + Item.UNAVAIL_MODE_INFO: 'info' + } + ) + + self.fields['available_until_mode'].widget = ButtonGroupRadioSelect( + choices=self.fields['available_until_mode'].choices, + option_icons={ + Item.UNAVAIL_MODE_HIDDEN: 'eye-slash', + Item.UNAVAIL_MODE_INFO: 'info' + } + ) + self.meta_fields = [] meta_defaults = {} if self.instance.pk: @@ -892,7 +943,9 @@ class ItemVariationForm(I18nModelForm): 'checkin_attention', 'checkin_text', 'available_from', + 'available_from_mode', 'available_until', + 'available_until_mode', 'sales_channels', 'hide_without_voucher', ] diff --git a/src/pretix/control/forms/renderers.py b/src/pretix/control/forms/renderers.py index 74de91304..3a49b8bfb 100644 --- a/src/pretix/control/forms/renderers.py +++ b/src/pretix/control/forms/renderers.py @@ -100,6 +100,22 @@ class ControlFieldRenderer(FieldRenderer): return '
{html}
'.format(klass=self.get_form_group_class(), html=html, attrs=attrs) +class ControlFieldWithVisibilityRenderer(ControlFieldRenderer): + def __init__(self, *args, **kwargs): + kwargs['layout'] = 'horizontal' + kwargs['horizontal_field_class'] = 'col-md-7' + self.visibility_field = kwargs['visibility_field'] + super().__init__(*args, **kwargs) + + def render_visibility_field(self): + return self.visibility_field.as_widget(attrs=self.visibility_field.field.widget.attrs) + + def wrap_field(self, html): + html = super().wrap_field(html) + html += '
' + self.render_visibility_field() + '
' + return html + + class BulkEditMixin: def __init__(self, *args, **kwargs): diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index 236ec69c3..f2fe4dbaa 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -260,6 +260,8 @@ class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm): super().__init__(*args, **kwargs) self.fields['price'].widget.attrs['placeholder'] = money_filter(self.item.default_price, self.item.event.currency, hide_currency=True) self.fields['price'].label = str(self.item) + self.available_from_mode = self.item.available_from_mode + self.available_until_mode = self.item.available_until_mode class Meta: model = SubEventItem @@ -287,6 +289,8 @@ class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelFor super().__init__(*args, **kwargs) self.fields['price'].widget.attrs['placeholder'] = money_filter(self.variation.price, self.item.event.currency, hide_currency=True) self.fields['price'].label = '{} – {}'.format(str(self.item), self.variation.value) + self.available_from_mode = self.variation.available_from_mode + self.available_until_mode = self.variation.available_until_mode class Meta: model = SubEventItemVariation diff --git a/src/pretix/control/templates/pretixcontrol/button_group_radio.html b/src/pretix/control/templates/pretixcontrol/button_group_radio.html new file mode 100644 index 000000000..f7de2dcc9 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/button_group_radio.html @@ -0,0 +1,6 @@ +{% with id=widget.attrs.id %}
{% for group, options, index in widget.optgroups %} + {% for option in options %} + {% include option.template_name with widget=option %} + {% endfor %} +{% endfor %} +
{% endwith %} \ No newline at end of file diff --git a/src/pretix/control/templates/pretixcontrol/button_group_radio_option.html b/src/pretix/control/templates/pretixcontrol/button_group_radio_option.html new file mode 100644 index 000000000..85a8c350f --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/button_group_radio_option.html @@ -0,0 +1,2 @@ + +{% include "django/forms/widgets/input.html" %} diff --git a/src/pretix/control/templates/pretixcontrol/item/include_variations.html b/src/pretix/control/templates/pretixcontrol/item/include_variations.html index 4d538dcaa..319a8b25c 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_variations.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_variations.html @@ -95,8 +95,8 @@ {% endif %} - {% bootstrap_field form.available_from layout="control" %} - {% bootstrap_field form.available_until layout="control" %} + {% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %} + {% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %} {% bootstrap_field form.sales_channels layout="control" %} {% bootstrap_field form.hide_without_voucher layout="control" %} {% bootstrap_field form.require_approval layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 6594fcd9d..4927c32d9 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -152,27 +152,28 @@
{% trans "Availability" %} - {% bootstrap_field form.sales_channels layout="control" %} - {% bootstrap_field form.available_from layout="control" %} - {% bootstrap_field form.available_until layout="control" %} - {% bootstrap_field form.max_per_order layout="control" %} - {% bootstrap_field form.min_per_order layout="control" %} - {% bootstrap_field form.require_voucher layout="control" %} - {% bootstrap_field form.hide_without_voucher layout="control" %} - {% bootstrap_field form.require_bundling layout="control" %} + + + {% bootstrap_field form.sales_channels layout="control" horizontal_field_class="col-md-7" %} + {% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %} + {% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %} + {% bootstrap_field form.max_per_order layout="control" horizontal_field_class="col-md-7" %} + {% bootstrap_field form.min_per_order layout="control" horizontal_field_class="col-md-7" %} + {% bootstrap_field form.require_voucher visibility_field=form.hide_without_voucher layout="control_with_visibility" %} + {% bootstrap_field form.require_bundling layout="control" horizontal_field_class="col-md-7" %} {% if form.require_membership %} {% bootstrap_field form.require_membership layout="control" %}
- {% bootstrap_field form.require_membership_types layout="control" %} - {% bootstrap_field form.require_membership_hidden layout="control" %} + {% bootstrap_field form.require_membership_types layout="control" horizontal_field_class="col-md-7" %} + {% bootstrap_field form.require_membership_hidden layout="control" horizontal_field_class="col-md-7" %}
{% endif %} - {% bootstrap_field form.allow_cancel layout="control" %} - {% bootstrap_field form.allow_waitinglist layout="control" %} + {% bootstrap_field form.allow_cancel layout="control" horizontal_field_class="col-md-7" %} + {% bootstrap_field form.allow_waitinglist layout="control" horizontal_field_class="col-md-7" %} {% if form.hidden_if_available %} - {% bootstrap_field form.hidden_if_available layout="control" %} + {% bootstrap_field form.hidden_if_available layout="control" horizontal_field_class="col-md-7" %} {% endif %} - {% bootstrap_field form.hidden_if_item_available layout="control" %} + {% bootstrap_field form.hidden_if_item_available layout="control" horizontal_field_class="col-md-7" %}
{% for v in formsets.values %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html index 949cd4873..1caaabcb2 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html @@ -503,11 +503,13 @@
-
+ + {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}
{% bootstrap_field f.rel_available_from form_group_class="" layout="inline" %}
-
+ + {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}
{% bootstrap_field f.rel_available_until form_group_class="" layout="inline" %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html index 36c8d9f7d..4df48ebee 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html @@ -167,11 +167,13 @@
-
+ + {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}
{% bootstrap_field f.available_from form_group_class="" layout="bulkedit_inline" %}
-
+ + {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}
{% bootstrap_field f.available_until form_group_class="" layout="bulkedit_inline" %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/detail.html b/src/pretix/control/templates/pretixcontrol/subevents/detail.html index 7bd053583..153210895 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/detail.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/detail.html @@ -145,11 +145,13 @@
-
- {% bootstrap_field f.available_from form_group_class="" layout="inline" %} + + {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}
+ {% bootstrap_field f.available_from form_group_class="foo" layout="inline" %}
-
+ + {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}
{% bootstrap_field f.available_until form_group_class="" layout="inline" %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/fragment_unavail_mode_indicator.html b/src/pretix/control/templates/pretixcontrol/subevents/fragment_unavail_mode_indicator.html new file mode 100644 index 000000000..9be415651 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/subevents/fragment_unavail_mode_indicator.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% if mode == "hide" %} + +{% else %} + +{% endif %} \ No newline at end of file diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index e1fa7aece..fdbc046b2 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -1493,6 +1493,8 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE messages.info(self.request, _("You disabled this item, but it is still part of a product bundle. " "Your participants won't be able to buy the bundle unless you remove this " "item from it.")) + if ctx['item'].hide_without_voucher: + ctx['item'].require_voucher = True ctx['sales_channels'] = get_all_sales_channels() return ctx diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html b/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html index 0f4bedebc..62a391d61 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html @@ -1,6 +1,19 @@ {% load i18n %} {% load eventurl %} -{% if avail <= 10 %} + +{% if item.current_unavailability_reason == 'require_voucher' %} + +{% elif item.current_unavailability_reason == 'available_from' or var.current_unavailability_reason == 'available_from' %} +
+

{% trans "Not available yet." %}

+
+{% elif item.current_unavailability_reason == 'available_until' or var.current_unavailability_reason == 'available_until' %} +
+

{% trans "Not available any more." %}

+
+{% elif avail <= 10 %}
{% if price != None and not price %} {% trans "FULLY BOOKED" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html b/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html index bd9d8ce29..7a98bc2d6 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html @@ -185,11 +185,7 @@ {% endif %}

- {% if item.require_voucher %} - - {% elif var.cached_availability.0 == 100 %} + {% if var.cached_availability.0 == 100 and not item.current_unavailability_reason and not var.current_unavailability_reason %}
{% if var.order_max == 1 %}
- {% if item.require_voucher %} - - {% elif item.cached_availability.0 == 100 %} + {% if item.cached_availability.0 == 100 and not item.current_unavailability_reason %}
{% if item.order_max == 1 %}
- {% if var.cached_availability.0 == 100 %} + {% if var.cached_availability.0 == 100 and not item.current_unavailability_reason %}
{% if max_times > 1 %} {% if var.order_max == 1 %} @@ -251,7 +251,7 @@ {% endif %}
{% else %} - {% include "pretixpresale/event/fragment_availability.html" with price=var.display_price.gross avail=var.cached_availability.0 %} + {% include "pretixpresale/event/fragment_availability.html" with price=var.display_price.gross avail=var.cached_availability.0 item=item %} {% endif %}
@@ -363,7 +363,7 @@ {% endif %}

- {% if item.cached_availability.0 == 100 %} + {% if item.cached_availability.0 == 100 and not item.current_unavailability_reason %}
{% if max_times > 1 %} {% if item.order_max == 1 %} @@ -404,7 +404,7 @@ {% endif %}
{% else %} - {% include "pretixpresale/event/fragment_availability.html" with price=item.display_price.gross avail=item.cached_availability.0 %} + {% include "pretixpresale/event/fragment_availability.html" with price=item.display_price.gross avail=item.cached_availability.0 item=item %} {% endif %}
diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index d567b398d..7af403ba5 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -48,6 +48,7 @@ from django.core.exceptions import PermissionDenied from django.db.models import ( Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value, ) +from django.db.models.lookups import Exact from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, render from django.utils.decorators import method_decorator @@ -118,8 +119,8 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require requires_seat = Value(0, output_field=IntegerField()) variation_q = ( - Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & - Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) + Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_mode='info')) & + Q(Q(available_until__isnull=True) | Q(available_until__gte=now()) | Q(available_until_mode='info')) ) if not voucher or not voucher.show_hidden_items: variation_q &= Q(hide_without_voucher=False) @@ -135,7 +136,9 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate( subevent_disabled=Exists( SubEventItemVariation.objects.filter( - Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()), + Q(disabled=True) + | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=now())) + | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=now())), variation_id=OuterRef('pk'), subevent=subevent, ) @@ -205,7 +208,9 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require has_variations=Count('variations'), subevent_disabled=Exists( SubEventItem.objects.filter( - Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()), + Q(disabled=True) + | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=now())) + | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=now())), item_id=OuterRef('pk'), subevent=subevent, ) @@ -301,6 +306,8 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require item._remove = True continue + item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent) + item.description = str(item.description) for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent): if resp: @@ -415,6 +422,8 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require if not display_add_to_cart: display_add_to_cart = not item.requires_seat and var.order_max > 0 + var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent) + item.original_price = ( item.tax(item.original_price, currency=event.currency, include_bundled=True, base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 3804e8537..0f463c34a 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -272,7 +272,7 @@ class WidgetAPIProductList(EventListMixin, View): 'picture_fullsize': get_picture(self.request.event, item.picture) if item.picture else None, 'description': str(rich_text(item.description, safelinks=False)) if item.description else None, 'has_variations': item.has_variations, - 'require_voucher': item.require_voucher, + 'current_unavailability_reason': item.current_unavailability_reason, 'order_min': item.min_per_order, 'order_max': item.order_max if not item.has_variations else None, 'price': price_dict(item, item.display_price) if not item.has_variations else None, @@ -317,6 +317,7 @@ class WidgetAPIProductList(EventListMixin, View): var.cached_availability[0], var.cached_availability[1] if item.do_show_quota_left else None ], + 'current_unavailability_reason': var.current_unavailability_reason, } for var in item.available_variations if (not variation_filter or var.id in variation_filter) ] diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 823d34558..29e14be83 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -686,6 +686,7 @@ BOOTSTRAP3 = { 'default': 'pretix.base.forms.renderers.FieldRenderer', 'inline': 'pretix.base.forms.renderers.InlineFieldRenderer', 'control': 'pretix.control.forms.renderers.ControlFieldRenderer', + 'control_with_visibility': 'pretix.control.forms.renderers.ControlFieldWithVisibilityRenderer', 'bulkedit': 'pretix.control.forms.renderers.BulkEditFieldRenderer', 'bulkedit_inline': 'pretix.control.forms.renderers.InlineBulkEditFieldRenderer', 'checkout': 'pretix.presale.forms.renderers.CheckoutFieldRenderer', diff --git a/src/pretix/static/pretixbase/scss/_theme.scss b/src/pretix/static/pretixbase/scss/_theme.scss index 7768948f3..882fd7de3 100644 --- a/src/pretix/static/pretixbase/scss/_theme.scss +++ b/src/pretix/static/pretixbase/scss/_theme.scss @@ -100,6 +100,16 @@ input[type=number]::-webkit-outer-spin-button { outline: 0; } +.btn-primary-if-active { + @include button-variant($btn-default-color, $btn-default-bg, $btn-default-border); + box-shadow: 0px 0px 0px 1px #cccccc inset; + box-sizing: border-box; + + &.active { + @include button-variant($btn-primary-color, $btn-primary-bg, $btn-primary-border); + } +} + .btn-invisible { background: transparent; border: transparent; diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index b6d6f8ac5..8e587ccc6 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -908,6 +908,10 @@ tbody th { display: block !important; } +.btn-group .btn:not(:first-child) { + margin-left: -1px; +} + @import "../../pretixbase/scss/_rtl.scss"; @import "../../bootstrap/scss/_rtl.scss"; @import "_rtl.scss"; diff --git a/src/pretix/static/pretixpresale/js/widget/widget.js b/src/pretix/static/pretixpresale/js/widget/widget.js index c1a91f235..e3928988f 100644 --- a/src/pretix/static/pretixpresale/js/widget/widget.js +++ b/src/pretix/static/pretixpresale/js/widget/widget.js @@ -31,7 +31,10 @@ var strings = { 'tax_incl_mixed': django.pgettext('widget', 'incl. taxes'), 'tax_plus_mixed': django.pgettext('widget', 'plus taxes'), 'quota_left': django.pgettext('widget', 'currently available: %s'), - 'voucher_required': django.pgettext('widget', 'Only available with a voucher'), + 'unavailable_require_voucher': django.pgettext('widget', 'Only available with a voucher'), + 'unavailable_available_from': django.pgettext('widget', 'Not yet available'), + 'unavailable_available_until': django.pgettext('widget', 'Not available anymore'), + 'unavailable_active': django.pgettext('widget', 'Currently not available'), 'order_min': django.pgettext('widget', 'minimum amount to order: %s'), 'exit': django.pgettext('widget', 'Close ticket shop'), 'loading_error': django.pgettext('widget', 'The ticket shop could not be loaded.'), @@ -118,17 +121,8 @@ var getISOWeeks = function (y) { /* HTTP API Call helpers */ var api = { - '_getXHR': function () { - try { - return new window.XMLHttpRequest(); - } catch (e) { - // explicitly bubble up the exception if not found - return new window.ActiveXObject('Microsoft.XMLHTTP'); - } - }, - '_getJSON': function (endpoint, callback, err_callback) { - var xhr = api._getXHR(); + var xhr = new window.XMLHttpRequest(); xhr.open("GET", endpoint, true); xhr.onload = function (e) { if (xhr.readyState === 4) { @@ -160,7 +154,7 @@ var api = { return encodeURIComponent(el.name) + '=' + encodeURIComponent(el.value); }).join('&'); - var xhr = api._getXHR(); + var xhr = new window.XMLHttpRequest(); xhr.open("POST", endpoint, true); xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); xhr.onload = function (e) { @@ -199,22 +193,27 @@ var widget_id = makeid(16); /* Vue Components */ Vue.component('availbox', { template: ('
' - + '
' - + '' + strings.voucher_required + '' + + '' + '
' + + ' v-else-if="unavailability_reason_message">' + + '{{unavailability_reason_message}}' + + '
' + + '
' + strings.reserved + '
' + '
' + + ' v-else-if="avail[0] <= 10">' + strings.sold_out + '
' + '' - + '
' + + '
' + '