Fix #1001 -- Add product bundles (#1041)

* Data model + Editor

* Cart and order management

* Rebase migrations

* Fix typos, add tests on cart handling

* Add tests for checkout and quotas

* Add API endpoints

* Validation of settings

* Front page tax display

* Voucher handling

* Widget foo

* Show correct net pricing

* Front page tests

* reverse charge foo

* Allow to require bundling

* Fix test failure on postgres
This commit is contained in:
Raphael Michel
2019-03-22 14:48:48 +00:00
committed by GitHub
parent c4b18a4c81
commit 90f881c48e
34 changed files with 2747 additions and 153 deletions

View File

@@ -13,7 +13,7 @@ from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn
from pretix.base.models.items import ItemAddOn, ItemBundle
from pretix.base.signals import item_copy_data
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2
@@ -300,6 +300,12 @@ class ItemCreateForm(I18nModelForm):
if self.cleaned_data.get('copy_from'):
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
for a in self.cleaned_data['copy_from'].addons.all():
instance.addons.create(addon_category=a.addon_category, min_count=a.min_count, max_count=a.max_count,
price_included=a.price_included, position=a.position)
for b in self.cleaned_data['copy_from'].bundles.all():
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
@@ -391,7 +397,8 @@ class ItemUpdateForm(I18nModelForm):
'min_per_order',
'checkin_attention',
'generate_tickets',
'original_price'
'original_price',
'require_bundling',
]
field_classes = {
'available_from': SplitDateTimeField,
@@ -522,3 +529,100 @@ class ItemAddOnForm(I18nModelForm):
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
'available add-ons are sold out.')
}
class ItemBundleFormSet(I18nFormSet):
def __init__(self, *args, **kwargs):
self.event = kwargs.get('event')
self.item = kwargs.pop('item')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
kwargs['item'] = self.item
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
item=self.item,
event=self.event
)
self.add_fields(form, None)
return form
class ItemBundleForm(I18nModelForm):
itemvar = forms.ChoiceField(label=_('Bundled product'))
def __init__(self, *args, **kwargs):
self.item = kwargs.pop('item')
super().__init__(*args, **kwargs)
instance = kwargs.get('instance', None)
initial = kwargs.get('initial', {})
if instance:
try:
if instance.bundled_variation:
initial['itemvar'] = '%d-%d' % (instance.bundled_item.pk, instance.bundled_variation.pk)
elif instance.bundled_item:
initial['itemvar'] = str(instance.bundled_item.pk)
except Item.DoesNotExist:
pass
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
choices = []
for i in self.event.items.prefetch_related('variations').all():
pname = str(i)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all())
if variations:
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s' % (pname, v.value)))
else:
choices.append((str(i.pk), '%s' % pname))
self.fields['itemvar'].choices = choices
change_decimal_field(self.fields['designated_price'], self.event.currency)
def clean(self):
d = super().clean()
if 'itemvar' in self.cleaned_data:
if '-' in self.cleaned_data['itemvar']:
itemid, varid = self.cleaned_data['itemvar'].split('-')
else:
itemid, varid = self.cleaned_data['itemvar'], None
item = Item.objects.get(pk=itemid, event=self.event)
if varid:
variation = ItemVariation.objects.get(pk=varid, item=item)
else:
variation = None
if item == self.item:
raise ValidationError(_("The bundled item must not be the same item as the bundling one."))
if item.bundles.exists():
raise ValidationError(_("The bundled item must not have bundles on its own."))
self.instance.bundled_item = item
self.instance.bundled_variation = variation
return d
class Meta:
model = ItemBundle
localized_fields = '__all__'
fields = [
'count',
'designated_price',
]

View File

@@ -22,6 +22,11 @@
{% trans "Add-Ons" %}
</a>
</li>
<li {% if "event.item.bundles" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.item.bundles' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
{% trans "Bundled products" %}
</a>
</li>
</ul>
{% else %}
<h1>{% trans "Create product" %}</h1>

View File

@@ -0,0 +1,80 @@
{% extends "pretixcontrol/item/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block inside %}
<p>
{% blocktrans trimmed %}
With bundles, you can specify products that are always automatically added as add-ons in the cart for this product.
{% endblocktrans %}
</p>
<form class="form-horizontal branches" method="post" action="">
{% csrf_token %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Bundled product" %}</h3>
</div>
<div class="col-sm-4 text-right">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.itemvar layout="control" %}
{% bootstrap_field form.count layout="control" %}
{% bootstrap_field form.designated_price layout="control" %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Bundled product" %}</h3>
</div>
<div class="col-sm-4 text-right">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.itemvar layout="control" %}
{% bootstrap_field formset.empty_form.count layout="control" %}
{% bootstrap_field formset.empty_form.designated_price layout="control" %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new bundled product" %}</button>
</p>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -33,6 +33,7 @@
{% 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.allow_cancel layout="control" %}
</fieldset>
<fieldset>

View File

@@ -150,6 +150,8 @@ urlpatterns = [
name='event.item.variations'),
url(r'^items/(?P<item>\d+)/addons', item.ItemAddOns.as_view(),
name='event.item.addons'),
url(r'^items/(?P<item>\d+)/bundles', item.ItemBundles.as_view(),
name='event.item.bundles'),
url(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
url(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
url(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),

View File

@@ -22,11 +22,11 @@ from pretix.base.models import (
QuestionAnswer, QuestionOption, Quota, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn
from pretix.base.models.items import ItemAddOn, ItemBundle
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemCreateForm,
ItemUpdateForm, ItemVariationForm, ItemVariationsFormSet, QuestionForm,
QuestionOptionForm, QuotaForm,
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -1043,6 +1043,92 @@ class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
return context
class ItemBundles(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/item/bundles.html'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.item = None
@cached_property
def formset(self):
formsetclass = inlineformset_factory(
Item, ItemBundle,
form=ItemBundleForm, formset=ItemBundleFormSet,
fk_name='base_item',
can_order=False, can_delete=True, extra=0
)
return formsetclass(self.request.POST if self.request.method == "POST" else None,
queryset=ItemBundle.objects.filter(base_item=self.get_object()),
event=self.request.event, item=self.item)
def post(self, request, *args, **kwargs):
with transaction.atomic():
if self.formset.is_valid():
for form in self.formset.deleted_forms:
if not form.instance.pk:
continue
self.get_object().log_action(
'pretix.event.item.bundles.removed', user=self.request.user, data={
'bundled_item': form.instance.bundled_item.pk,
'bundled_variation': (form.instance.bundled_variation.pk if form.instance.bundled_variation else None),
'count': form.instance.count,
'designated_price': str(form.instance.designated_price),
}
)
form.instance.delete()
form.instance.pk = None
forms = [
ef for ef in self.formset.forms
if ef not in self.formset.deleted_forms
]
for i, form in enumerate(forms):
form.instance.base_item = self.get_object()
created = not form.instance.pk
form.save()
if form.has_changed():
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
change_data['id'] = form.instance.pk
self.get_object().log_action(
'pretix.event.item.bundles.changed' if not created else
'pretix.event.item.bundles.added',
user=self.request.user, data=change_data
)
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
return self.get(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if self.get_object().category and self.get_object().category.is_addon:
messages.error(self.request, _('You cannot add bundles to a product that is only available as an add-on '
'itself.'))
return redirect(self.get_previous_url())
return super().get(request, *args, **kwargs)
def get_previous_url(self) -> str:
return reverse('control:event.item', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'item': self.get_object().id,
})
def get_success_url(self) -> str:
return reverse('control:event.item.bundles', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'item': self.get_object().id,
})
def get_context_data(self, **kwargs) -> dict:
context = super().get_context_data(**kwargs)
context['formset'] = self.formset
return context
class ItemDelete(EventPermissionRequiredMixin, DeleteView):
model = Item
template_name = 'pretixcontrol/item/delete.html'