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 '