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 <schreiber@rami.io>

* 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 <michel@rami.io>

* Add unavail mode indicator to bulk create and edit forms

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
Mira
2024-02-06 12:27:19 +01:00
committed by GitHub
parent 43facd1e43
commit 22f91f7aa2
30 changed files with 410 additions and 79 deletions

View File

@@ -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,

View File

@@ -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": {},

View File

@@ -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',

View File

@@ -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),
)
]

View File

@@ -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),
),
]

View File

@@ -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

View File

@@ -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'<strike class="text-muted">{escape(obj)}</strike>')
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

View File

@@ -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 its 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',
]

View File

@@ -100,6 +100,22 @@ class ControlFieldRenderer(FieldRenderer):
return '<div class="{klass}"{attrs}>{html}</div>'.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 += '<div class="col-md-2 text-right">' + self.render_visibility_field() + '</div>'
return html
class BulkEditMixin:
def __init__(self, *args, **kwargs):

View File

@@ -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

View File

@@ -0,0 +1,6 @@
{% with id=widget.attrs.id %}<div data-toggle="buttons"{% if id %} id="{{ id }}"{% endif %} class="btn-group btn-group-toggle {{ widget.attrs.class }}">{% for group, options, index in widget.optgroups %}
{% for option in options %}
{% include option.template_name with widget=option %}
{% endfor %}
{% endfor %}
</div>{% endwith %}

View File

@@ -0,0 +1,2 @@
<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %} class="btn btn-primary-if-active form-field-boundary {% if widget.attrs.checked %} active{% endif %}" title="{{ widget.label }}" data-toggle="tooltip">
{% include "django/forms/widgets/input.html" %} <span class="fa fa-{{ widget.attrs.icon }}"></span></label>

View File

@@ -95,8 +95,8 @@
</div>
</div>
{% 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" %}

View File

@@ -152,27 +152,28 @@
</fieldset>
<fieldset>
<legend>{% trans "Availability" %}</legend>
{% 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" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
{% 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" %}
</div>
{% 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" %}
</fieldset>
{% for v in formsets.values %}
<fieldset>

View File

@@ -503,11 +503,13 @@
</div>
<div class="form-group subevent-itemvar-group">
<div class="col-md-4 col-md-offset-3">
<label for="{{ f.rel_available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label><br>
<label for="{{ f.rel_available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br>
{% bootstrap_field f.rel_available_from form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
<label for="{{ f.rel_available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label><br>
<label for="{{ f.rel_available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{% bootstrap_field f.rel_available_until form_group_class="" layout="inline" %}
</div>
</div>

View File

@@ -167,11 +167,13 @@
</div>
<div class="form-group subevent-itemvar-group">
<div class="col-md-4 col-md-offset-3">
<label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label><br>
<label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br>
{% bootstrap_field f.available_from form_group_class="" layout="bulkedit_inline" %}
</div>
<div class="col-md-5">
<label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label><br>
<label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{% bootstrap_field f.available_until form_group_class="" layout="bulkedit_inline" %}
</div>
</div>

View File

@@ -145,11 +145,13 @@
</div>
<div class="form-group subevent-itemvar-group">
<div class="col-md-4 col-md-offset-3">
<label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label><br>
{% bootstrap_field f.available_from form_group_class="" layout="inline" %}
<label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br>
{% bootstrap_field f.available_from form_group_class="foo" layout="inline" %}
</div>
<div class="col-md-4">
<label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label><br>
<label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{% bootstrap_field f.available_until form_group_class="" layout="inline" %}
</div>
</div>

View File

@@ -0,0 +1,6 @@
{% load i18n %}
{% if mode == "hide" %}
<span class="pull-right text-muted unavail-mode-indicator" data-toggle="tooltip" title="{% trans "Hide product if unavailable" %}. {% if f.variation %}{% trans "You can change this option in the variation settings." %}{% else %}{% trans "You can change this option in the product settings." %}{% endif %}"><span class="fa fa-eye-slash"></span></span>
{% else %}
<span class="pull-right text-muted unavail-mode-indicator" data-toggle="tooltip" title="{% trans "Show info text if unavailable" %}. {% if f.variation %}{% trans "You can change this option in the variation settings." %}{% else %}{% trans "You can change this option in the product settings." %}{% endif %}"><span class="fa fa-info-circle"></span></span>
{% endif %}

View File

@@ -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

View File

@@ -1,6 +1,19 @@
{% load i18n %}
{% load eventurl %}
{% if avail <= 10 %}
{% if item.current_unavailability_reason == 'require_voucher' %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small><a href="#voucher">{% trans "Enter a voucher code below to buy this product." %}</a></small></p>
</div>
{% elif item.current_unavailability_reason == 'available_from' or var.current_unavailability_reason == 'available_from' %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small>{% trans "Not available yet." %}</small></p>
</div>
{% elif item.current_unavailability_reason == 'available_until' or var.current_unavailability_reason == 'available_until' %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small>{% trans "Not available any more." %}</small></p>
</div>
{% elif avail <= 10 %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box gone">
{% if price != None and not price %}
<strong>{% trans "FULLY BOOKED" %}</strong>

View File

@@ -185,11 +185,7 @@
{% endif %}
</p>
</div>
{% if item.require_voucher %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small><a href="#voucher">{% trans "Enter a voucher code below to buy this ticket." %}</a></small></p>
</div>
{% elif var.cached_availability.0 == 100 %}
{% if var.cached_availability.0 == 100 and not item.current_unavailability_reason and not var.current_unavailability_reason %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box available">
{% if var.order_max == 1 %}
<label class="btn btn-default btn-checkbox">
@@ -338,11 +334,7 @@
{% endif %}
</p>
</div>
{% if item.require_voucher %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small><a href="#voucher">{% trans "Enter a voucher code below to buy this ticket." %}</a></small></p>
</div>
{% elif item.cached_availability.0 == 100 %}
{% if item.cached_availability.0 == 100 and not item.current_unavailability_reason %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box available">
{% if item.order_max == 1 %}
<label class="btn btn-default btn-checkbox">

View File

@@ -212,7 +212,7 @@
{% endif %}
</p>
</div>
{% if var.cached_availability.0 == 100 %}
{% if var.cached_availability.0 == 100 and not item.current_unavailability_reason %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box available radio-box">
{% if max_times > 1 %}
{% if var.order_max == 1 %}
@@ -251,7 +251,7 @@
{% endif %}
</div>
{% 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 %}
<div class="clearfix"></div>
</article>
@@ -363,7 +363,7 @@
{% endif %}
</p>
</div>
{% if item.cached_availability.0 == 100 %}
{% if item.cached_availability.0 == 100 and not item.current_unavailability_reason %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box available radio-box">
{% if max_times > 1 %}
{% if item.order_max == 1 %}
@@ -404,7 +404,7 @@
{% endif %}
</div>
{% 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 %}
<div class="clearfix"></div>
</article>

View File

@@ -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

View File

@@ -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)
]

View File

@@ -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',

View File

@@ -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;

View File

@@ -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";

View File

@@ -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: ('<div class="pretix-widget-availability-box">'
+ '<div class="pretix-widget-availability-unavailable" v-if="require_voucher">'
+ '<small><a @click.prevent.stop="focus_voucher_field" role="button">' + strings.voucher_required + '</a></small>'
+ '<div class="pretix-widget-availability-unavailable"'
+ ' v-if="item.current_unavailability_reason === \'require_voucher\'">'
+ '<small><a @click.prevent.stop="focus_voucher_field" role="button">{{unavailability_reason_message}}</a></small>'
+ '</div>'
+ '<div class="pretix-widget-availability-unavailable"'
+ ' v-if="!require_voucher && avail[0] < 100 && avail[0] > 10">'
+ ' v-else-if="unavailability_reason_message">'
+ '<small>{{unavailability_reason_message}}</small>'
+ '</div>'
+ '<div class="pretix-widget-availability-unavailable"'
+ ' v-else-if="avail[0] < 100 && avail[0] > 10">'
+ strings.reserved
+ '</div>'
+ '<div class="pretix-widget-availability-gone" '
+ ' v-if="!require_voucher && avail[0] <= 10">'
+ ' v-else-if="avail[0] <= 10">'
+ strings.sold_out
+ '</div>'
+ '<div class="pretix-widget-waiting-list-link"'
+ ' v-if="waiting_list_show">'
+ '<a :href="waiting_list_url" target="_blank" @click="$root.open_link_in_frame">' + strings.waiting_list + '</a>'
+ '</div>'
+ '<div class="pretix-widget-availability-available" v-if="!require_voucher && avail[0] === 100">'
+ '<div class="pretix-widget-availability-available" v-if="!unavailability_reason_message && avail[0] === 100">'
+ '<label class="pretix-widget-item-count-single-label pretix-widget-btn-checkbox" v-if="order_max === 1 && $root.single_item_select == \'button\'">'
+ '<input type="checkbox" value="1" :checked="!!amount_selected" @change="amount_selected = $event.target.checked" :name="input_name"'
+ ' v-bind:aria-label="label_select_item"'
@@ -255,8 +254,12 @@ Vue.component('availbox', {
'pretix-widget-item-count-group': !this.$root.use_native_spinners
}
},
require_voucher: function () {
return this.item.require_voucher && !this.$root.voucher_code
unavailability_reason_message: function () {
var reason = this.item.current_unavailability_reason || this.variation.current_unavailability_reason;
if (reason) {
return strings["unavailable_" + reason] || reason;
}
return "";
},
amount_selected: {
cache: false,

View File

@@ -270,6 +270,8 @@ TEST_ITEM_RES = {
"picture": None,
"available_from": None,
"available_until": None,
"available_from_mode": "hide",
"available_until_mode": "hide",
"require_bundling": False,
"require_voucher": False,
"hide_without_voucher": False,
@@ -401,6 +403,8 @@ def test_item_detail_variations(token_client, organizer, event, team, item):
"sales_channels": list(get_all_sales_channels().keys()),
"available_from": None,
"available_until": None,
"available_from_mode": "hide",
"available_until_mode": "hide",
"hide_without_voucher": False,
"original_price": None,
"meta_data": {}
@@ -1330,6 +1334,8 @@ TEST_VARIATIONS_RES = {
"sales_channels": list(get_all_sales_channels().keys()),
"available_from": None,
"available_until": None,
"available_from_mode": "hide",
"available_until_mode": "hide",
"hide_without_voucher": False,
"original_price": None,
"free_price_suggestion": None,
@@ -1353,6 +1359,8 @@ TEST_VARIATIONS_UPDATE = {
"sales_channels": ["web"],
"available_from": None,
"available_until": None,
"available_from_mode": "hide",
"available_until_mode": "hide",
"hide_without_voucher": False,
"original_price": None,
"free_price_suggestion": None,

View File

@@ -186,7 +186,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
{
"items": [
{
"require_voucher": False,
"current_unavailability_reason": None,
"order_min": None,
"max_price": None,
"price": {"gross": "23.00", "net": "19.33", "tax": "3.67", "name": "", "rate": "19.00", "includes_mixed_tax_rate": False},
@@ -207,7 +207,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"order_max": 4
},
{
"require_voucher": False,
"current_unavailability_reason": None,
"order_min": None,
"max_price": "14.00",
"price": None,
@@ -231,7 +231,8 @@ class WidgetCartTest(CartTestMixin, TestCase):
"rate": "19.00", "includes_mixed_tax_rate": False},
"description": None,
"avail": [100, None],
"order_max": 2
"order_max": 2,
"current_unavailability_reason": None,
},
{
"value": "Blue",
@@ -243,7 +244,8 @@ class WidgetCartTest(CartTestMixin, TestCase):
"rate": "19.00", "includes_mixed_tax_rate": False},
"description": None,
"avail": [100, None],
"order_max": 2
"order_max": 2,
"current_unavailability_reason": None,
}
],
"id": self.shirt.pk,
@@ -273,7 +275,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
{
"items": [
{
"require_voucher": False,
"current_unavailability_reason": None,
"order_min": None,
"max_price": None,
"price": {"gross": "23.00", "net": "19.33", "tax": "3.67", "name": "", "rate": "19.00",
@@ -322,7 +324,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
{
"items": [
{
"require_voucher": False,
"current_unavailability_reason": None,
"order_min": None,
"max_price": "14.00",
"price": None,
@@ -346,7 +348,8 @@ class WidgetCartTest(CartTestMixin, TestCase):
"rate": "19.00", "includes_mixed_tax_rate": False},
"description": None,
"avail": [100, None],
"order_max": 2
"order_max": 2,
"current_unavailability_reason": None,
}
],
"id": self.shirt.pk,
@@ -389,7 +392,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
{
"items": [
{
"require_voucher": False,
"current_unavailability_reason": None,
"order_min": None,
"max_price": None,
"price": {"gross": "23.00", "net": "19.33", "tax": "3.67", "name": "", "rate": "19.00", "includes_mixed_tax_rate": False},
@@ -457,7 +460,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
'has_variations': 2,
"allow_waitinglist": True,
"mandatory_priced_addons": False,
'require_voucher': False,
'current_unavailability_reason': None,
'order_min': None,
'order_max': None,
'price': None,
@@ -490,7 +493,8 @@ class WidgetCartTest(CartTestMixin, TestCase):
'name': '',
'includes_mixed_tax_rate': False
},
'avail': [100, None]
'avail': [100, None],
'current_unavailability_reason': None,
},
]
}