mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user