Cross-selling category configuration

This commit is contained in:
Mira Weller
2024-05-27 16:36:56 +02:00
parent c49f42301c
commit f19e5bef72
5 changed files with 90 additions and 5 deletions

View File

@@ -441,7 +441,7 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = ItemCategory model = ItemCategory
fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon') fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon', 'cross_selling_mode', 'cross_selling_condition')
class QuestionOptionSerializer(I18nAwareModelSerializer): class QuestionOptionSerializer(I18nAwareModelSerializer):

View File

@@ -111,6 +111,31 @@ class ItemCategory(LoggedModel):
'only be bought in combination with a product that has this category configured as a possible ' 'only be bought in combination with a product that has this category configured as a possible '
'source for add-ons.') 'source for add-ons.')
) )
CROSS_SELLING_MODES = (
(None, _('Normal category')),
('both', _('Combined category')),
('only', _('Cross-selling category')),
)
cross_selling_mode = models.CharField(
choices=CROSS_SELLING_MODES,
null=True,
)
CROSS_SELLING_CONDITION = (
('always', _('Always show in cross-selling step')),
('products', _('Only if the cart contains one of these products')),
('discounts', _('Only show products affected by discount rules')),
)
cross_selling_condition = models.CharField(
verbose_name=_("Cross-selling condition"),
choices=CROSS_SELLING_CONDITION,
null=True,
)
cross_selling_match_products = models.ManyToManyField(
'pretixbase.Item',
blank=True,
verbose_name=_("Cross-selling condition products"),
related_name="matched_by_cross_selling_categories",
)
class Meta: class Meta:
verbose_name = _("Product category") verbose_name = _("Product category")

View File

@@ -41,6 +41,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Max from django.db.models import Max
from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME from django.forms.formsets import DELETION_FIELD_NAME
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -79,11 +80,63 @@ class CategoryForm(I18nModelForm):
'name', 'name',
'internal_name', 'internal_name',
'description', 'description',
'is_addon' #'is_addon'
'cross_selling_condition',
'cross_selling_match_products',
] ]
widgets = { widgets = {
'description': I18nMarkdownTextarea, 'description': I18nMarkdownTextarea,
'cross_selling_condition': RadioSelect,
#'is_addon': BooleanRadio(
# 'normal', _('Normal category'),
# 'addon', _('Products in this category are add-on products'),
#)
} }
field_classes = {
'cross_selling_match_products': SafeModelMultipleChoiceField,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
#self.fields['is_addon'].label = _('Category type')
self.fields['category_type'] = ChoiceField(widget=RadioSelect, choices=(
('normal', mark_safe('{} &nbsp; <span class="text-muted">{}</span>'.format(_('Normal category'), _('Products in this category are regular products displayed on the front page.'))),),
('addon', mark_safe('{} &nbsp; <span class="text-muted">{}</span>'.format(_('Add-on product category'), _('Products in this category are add-on products and can only be bought as add-ons.'))),),
('only', mark_safe('{} &nbsp; <span class="text-muted">{}</span>'.format(_('Cross-selling category'), _('Products in this category are regular products, but are only shown in the cross-selling step, according to the configuration below.'))),),
('both', mark_safe('{} &nbsp; <span class="text-muted">{}</span>'.format(_('Combined category'), _('Products in this category are regular products displayed on the front page, but are additionally shown in the cross-selling step, according to the configuration below.'))),),
))
self.fields['category_type'].initial = 'addon' if self.instance.is_addon else (self.instance.cross_selling_mode or 'normal')
#self.fields['show_in_cross_selling'] = BooleanField(widget=CheckboxInput(attrs={
# 'data-display-dependency': '#id_category_type_0',
#}),
# help_text='Products are additionally shown in the cross-selling step, according to the configuration below')
self.fields['cross_selling_condition'].widget.attrs['data-display-dependency'] = '#id_category_type_2,#id_category_type_3'
self.fields['cross_selling_condition'].widget.attrs['data-disable-dependent'] = 'true'
self.fields['cross_selling_condition'].widget.choices = self.fields['cross_selling_condition'].widget.choices[1:]
self.fields['cross_selling_condition'].required = False
self.fields['cross_selling_match_products'].widget = forms.CheckboxSelectMultiple(
attrs={
'class': 'scrolling-multiple-choice',
'data-display-dependency': '#id_cross_selling_condition_1'
}
)
self.fields['cross_selling_match_products'].queryset = self.event.items.all()
def clean(self):
d = super().clean()
if d.get('category_type') == 'only' or d.get('category_type') == 'both':
if not d.get('cross_selling_condition'):
raise ValidationError({'cross_selling_condition': [_('This field is required')]})
self.instance.cross_selling_mode = d.get('category_type')
else:
self.instance.cross_selling_mode = None
if d.get('category_type') == 'only' or d.get('category_type') == 'both' or d.get('category_type') == 'normal':
self.instance.is_addon = False
elif d.get('category_type') == 'addon':
self.instance.is_addon = True
d['category_type'] = 'normal'
return d
class QuestionForm(I18nModelForm): class QuestionForm(I18nModelForm):

View File

@@ -16,7 +16,9 @@
{% bootstrap_field form.internal_name layout="control" %} {% bootstrap_field form.internal_name layout="control" %}
</div> </div>
{% bootstrap_field form.description layout="control" %} {% bootstrap_field form.description layout="control" %}
{% bootstrap_field form.is_addon layout="control" %} {% bootstrap_field form.category_type layout="control" %}
{% bootstrap_field form.cross_selling_condition layout="control" %}
{% bootstrap_field form.cross_selling_match_products layout="control" %}
</fieldset> </fieldset>
</div> </div>
{% if category %} {% if category %}

View File

@@ -381,6 +381,9 @@ var form_handlers = function (el) {
enabled = !enabled; enabled = !enabled;
} }
var $toggling = dependent; var $toggling = dependent;
if (dependent.attr("data-disable-dependent")) {
$toggling.attr('disabled', !enabled);
}
if (dependent.get(0).tagName.toLowerCase() !== "div") { if (dependent.get(0).tagName.toLowerCase() !== "div") {
$toggling = dependent.closest('.form-group'); $toggling = dependent.closest('.form-group');
} }
@@ -395,8 +398,10 @@ var form_handlers = function (el) {
} }
}; };
update(); update();
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("change", update); dependency.each(function() {
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("dp.change", update); $(this).closest('.form-group').find('[name=' + $(this).attr("name") + ']').on("change", update);
$(this).closest('.form-group').find('[name=' + $(this).attr("name") + ']').on("dp.change", update);
})
}); });
el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () { el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () {