From 5f52963ce05808ddf68a7d1064bc2b8bf94151f1 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 1 Mar 2017 19:31:50 +0100 Subject: [PATCH] Add add-on products --- .../migrations/0052_auto_20170318_1748.py | 59 +++++++ src/pretix/base/models/items.py | 45 ++++- src/pretix/base/models/orders.py | 3 + src/pretix/base/services/cart.py | 162 +++++++++++++++++- src/pretix/control/forms/item.py | 59 ++++++- src/pretix/control/logdisplay.py | 3 + .../templates/pretixcontrol/item/addons.html | 86 ++++++++++ .../templates/pretixcontrol/item/base.html | 23 ++- .../pretixcontrol/items/category.html | 1 + src/pretix/control/urls.py | 2 + src/pretix/control/views/item.py | 74 +++++++- src/pretix/presale/checkoutflow.py | 118 ++++++++++++- src/pretix/presale/forms/checkout.py | 92 +++++++++- .../pretixpresale/event/checkout_addons.html | 70 ++++++++ 14 files changed, 772 insertions(+), 25 deletions(-) create mode 100644 src/pretix/base/migrations/0052_auto_20170318_1748.py create mode 100644 src/pretix/control/templates/pretixcontrol/item/addons.html create mode 100644 src/pretix/presale/templates/pretixpresale/event/checkout_addons.html diff --git a/src/pretix/base/migrations/0052_auto_20170318_1748.py b/src/pretix/base/migrations/0052_auto_20170318_1748.py new file mode 100644 index 000000000..c4b127d54 --- /dev/null +++ b/src/pretix/base/migrations/0052_auto_20170318_1748.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-18 17:48 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0051_auto_20170206_2027'), + ] + + operations = [ + migrations.CreateModel( + name='ItemAddOn', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')), + ('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')), + ], + ), + migrations.AddField( + model_name='cartposition', + name='addon_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CartPosition'), + ), + migrations.AddField( + model_name='itemcategory', + name='is_addon', + field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'), + ), + migrations.AddField( + model_name='orderposition', + name='addon_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPosition'), + ), + migrations.AlterField( + model_name='item', + name='allow_cancel', + field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'), + ), + migrations.AlterField( + model_name='item', + name='default_price', + field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'), + ), + migrations.AddField( + model_name='itemaddon', + name='addon_category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'), + ), + migrations.AddField( + model_name='itemaddon', + name='base_item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 20471bac4..793fb43a1 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -5,6 +5,7 @@ from decimal import Decimal from typing import Tuple from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.db.models import F, Func, Q, Sum from django.utils.functional import cached_property @@ -44,6 +45,13 @@ class ItemCategory(LoggedModel): position = models.IntegerField( default=0 ) + is_addon = models.BooleanField( + default=False, + verbose_name=_('Products in this category are add-on products'), + help_text=_('If selected, the products belonging to this category are not for sale on their own. They can ' + 'only be bought in combination with a product that has this category configured as a possible ' + 'source for add-ons.') + ) class Meta: verbose_name = _("Product category") @@ -51,6 +59,8 @@ class ItemCategory(LoggedModel): ordering = ('position', 'id') def __str__(self): + if self.is_addon: + return _('{category} (Add-On products)').format(category=str(self.name)) return str(self.name) def delete(self, *args, **kwargs): @@ -155,7 +165,8 @@ class Item(LoggedModel): verbose_name=_("Free price input"), help_text=_("If this option is active, your users can choose the price themselves. The price configured above " "is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect " - "additional donations for your event.") + "additional donations for your event. This is currently not supported for products that are " + "bought as an add-on to other products.") ) tax_rate = models.DecimalField( verbose_name=_("Taxes included in percent"), @@ -377,6 +388,38 @@ class ItemVariation(models.Model): return self.position < other.position +class ItemAddOn(models.Model): + """ + An instance of this model indicates that buying a ticket of the time ``base_item`` + allows you to add up to ``max_count`` items from the category ``addon_category`` + to your order that will be associated with the base item. + """ + base_item = models.ForeignKey( + Item, + related_name='addons' + ) + addon_category = models.ForeignKey( + ItemCategory, + related_name='addon_to', + verbose_name=_('Category') + ) + min_count = models.PositiveIntegerField( + default=0, + verbose_name=_('Minimum number') + ) + max_count = models.PositiveIntegerField( + default=1, + verbose_name=_('Maximum number') + ) + + class Meta: + unique_together = (('base_item', 'addon_category'),) + + def clean(self): + if self.max_count < self.min_count: + raise ValidationError(_('The minimum number needs to be lower than the maximum number.')) + + class Question(LoggedModel): """ A question is an input field that can be used to extend a ticket by custom information, diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index f149001bc..435319483 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -428,6 +428,9 @@ class AbstractPosition(models.Model): voucher = models.ForeignKey( 'Voucher', null=True, blank=True ) + addon_to = models.ForeignKey( + 'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons' + ) class Meta: abstract = True diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 8bb73c223..1a6528d8b 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1,4 +1,4 @@ -from collections import Counter, namedtuple +from collections import Counter, defaultdict, namedtuple from datetime import timedelta from decimal import Decimal from typing import List, Optional @@ -48,11 +48,17 @@ error_messages = { 'voucher_expired': _('This voucher is expired.'), 'voucher_invalid_item': _('This voucher is not valid for this product.'), 'voucher_required': _('You need a valid voucher code to order this product.'), + 'addon_invalid_base': _('You can not select an add-on for the selected product.'), + 'addon_duplicate_item': _('You can not select two variations of the same add-on product.'), + 'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'), + 'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the ' + 'product %(base)s.'), } class CartManager: - AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas')) + AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas', + 'addon_to')) RemoveOperation = namedtuple('RemoveOperation', ('position',)) ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', 'quotas')) @@ -100,9 +106,15 @@ class CartManager: def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]): self._items_cache.update( - {i.pk: i for i in self.event.items.prefetch_related('quotas').filter( - id__in=[i for i in item_ids if i and i not in self._items_cache] - )} + { + i.pk: i + for i + in self.event.items.select_related('category').prefetch_related( + 'addons', 'addons__addon_category', 'quotas' + ).filter( + id__in=[i for i in item_ids if i and i not in self._items_cache] + ) + } ) self._variations_cache.update( {v.pk: v for v in @@ -247,7 +259,8 @@ class CartManager: price = self._get_price(item, variation, voucher, i.get('price')) op = self.AddOperation( - count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas + count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas, + addon_to=False ) self._check_item_constraints(op) operations.append(op) @@ -280,6 +293,116 @@ class CartManager: for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]: self._operations.append(self.RemoveOperation(position=cp)) + def set_addons(self, addons): + self._update_items_cache( + [a['item'] for a in addons], + [a['variation'] for a in addons], + ) + + current_addons = defaultdict(dict) + input_addons = defaultdict(set) + selected_addons = defaultdict(set) + cpcache = {} + quota_diff = Counter() + operations = [] + available_categories = defaultdict(set) + toplevel_cp = self.positions.filter( + addon_to__isnull=True + ).prefetch_related( + 'addons', 'item__addons', 'item__addons__addon_category' + ).select_related('item', 'variation') + + for cp in toplevel_cp: + available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()} + cpcache[cp.pk] = cp + current_addons[cp] = { + (a.item_id, a.variation_id): a + for a in cp.addons.all() + } + + for a in addons: + # Check whether the specified items are part of what we just fetched from the database + # If they are not, the user supplied item IDs which either do not exist or belong to + # a different event + if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache): + raise CartError(error_messages['not_for_sale']) + + if a['addon_to'] not in cpcache: + raise CartError(error_messages['addon_invalid_base']) + + cp = cpcache[a['addon_to']] + item = self._items_cache[a['item']] + variation = self._variations_cache[a['variation']] if a['variation'] is not None else None + + if item.category_id not in available_categories[cp.pk]: + raise CartError(error_messages['addon_invalid_base']) + + # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. + quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all()) + if not quotas: + raise CartError(error_messages['unavailable']) + + if a['item'] in ([_a[0] for _a in input_addons[cp.id]]): + raise CartError(error_messages['addon_duplicate_item']) + + input_addons[cp.id].add((a['item'], a['variation'])) + selected_addons[cp.id, item.category_id].add((a['item'], a['variation'])) + + if (a['item'], a['variation']) not in current_addons[cp]: + for quota in quotas: + quota_diff[quota] += 1 + + price = self._get_price(item, variation, None, None) + + op = self.AddOperation( + count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, + addon_to=cp + ) + self._check_item_constraints(op) + operations.append(op) + + for cp in toplevel_cp: + item = cp.item + for iao in item.addons.all(): + selected = selected_addons[cp.id, iao.addon_category_id] + if len(selected) > iao.max_count: + # TODO: Proper i18n + # TODO: Proper pluralization + raise CartError( + error_messages['addon_max_count'], + { + 'base': str(item.name), + 'max': iao.max_count, + 'cat': str(iao.addon_category.name), + } + ) + elif len(selected) < iao.min_count: + # TODO: Proper i18n + # TODO: Proper pluralization + raise CartError( + error_messages['addon_min_count'], + { + 'base': str(item.name), + 'min': iao.min_count, + 'cat': str(iao.addon_category.name), + } + ) + + for cp, al in current_addons.items(): + for k, v in al.items(): + if k not in input_addons[cp.id]: + if v.expires > self.now_dt: + quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) + + for quota in quotas: + quota_diff[quota] -= 1 + + op = self.RemoveOperation(position=v) + operations.append(op) + + self._quota_diff += quota_diff + self._operations += operations + def _get_quota_availability(self): quotas_ok = {} for quota, count in self._quota_diff.items(): @@ -388,7 +511,8 @@ class CartManager: new_cart_positions.append(CartPosition( event=self.event, item=op.item, variation=op.variation, price=op.price, expires=self._expiry, - cart_id=self.cart_id, voucher=op.voucher + cart_id=self.cart_id, voucher=op.voucher, + addon_to=op.addon_to if op.addon_to else None )) elif isinstance(op, self.ExtendOperation): if available_count == 1: @@ -423,7 +547,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo """ Adds a list of items to a user's cart. :param event: The event ID in question - :param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher) + :param items: A list of dicts with the keys item, variation, number, custom_price, voucher :param session: Session ID of a guest :param coupon: A coupon that should also be reeemed :raises CartError: On any error that occured @@ -446,7 +570,7 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non """ Removes a list of items from a user's cart. :param event: The event ID in question - :param items: A list of tuple of the form (item id, variation id or None, number) + :param items: A list of dicts with the keys item, variation, number, custom_price, voucher :param session: Session ID of a guest """ with language(locale): @@ -460,3 +584,23 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) + + +@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) +def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None) -> None: + """ + Removes a list of items from a user's cart. + :param event: The event ID in question + :param addons: A list of dicts with the keys addon_to, item, variation + :param session: Session ID of a guest + """ + event = Event.objects.get(id=event) + try: + try: + cm = CartManager(event=event, cart_id=cart_id) + cm.set_addons(addons) + cm.commit() + except LockTimeoutException: + self.retry() + except (MaxRetriesExceededError, LockTimeoutException): + raise CartError(error_messages['busy']) diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 87b61d899..323cc5f26 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -11,6 +11,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 class CategoryForm(I18nModelForm): @@ -19,7 +20,8 @@ class CategoryForm(I18nModelForm): localized_fields = '__all__' fields = [ 'name', - 'description' + 'description', + 'is_addon' ] @@ -208,3 +210,58 @@ class ItemVariationForm(I18nModelForm): 'active', 'default_price', ] + + +class ItemAddOnsFormSet(I18nFormSet): + def __init__(self, *args, **kwargs): + self.event = kwargs.get('event') + super().__init__(*args, **kwargs) + + def _construct_form(self, i, **kwargs): + kwargs['event'] = self.event + return super()._construct_form(i, **kwargs) + + def clean(self): + super().clean() + categories = set() + for i in range(0, self.total_form_count()): + form = self.forms[i] + if self.can_delete: + if self._should_delete_form(form): + # This form is going to be deleted so any of its errors + # should not cause the entire formset to be invalid. + continue + + if form.cleaned_data['addon_category'] in categories: + raise ValidationError(_('You added the same add-on category twice')) + + categories.add(form.cleaned_data['addon_category']) + + @property + def empty_form(self): + self.is_valid() + form = self.form( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + locales=self.locales, + event=self.event + ) + self.add_fields(form, None) + return form + + +class ItemAddOnForm(I18nModelForm): + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + super().__init__(*args, **kwargs) + self.fields['addon_category'].queryset = self.event.categories.all() + + class Meta: + model = ItemAddOn + localized_fields = '__all__' + fields = [ + 'addon_category', + 'min_count', + 'max_count', + ] diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index c05dae008..63dbd10e3 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -95,6 +95,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.item.variation.added': _('The variation "{value}" has been created.'), 'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'), 'pretix.event.item.variation.changed': _('The variation "{value}" has been modified.'), + 'pretix.event.item.addons.added': _('An add-on has been added to this product.'), + 'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'), + 'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'), 'pretix.event.quota.added': _('The quota has been added.'), 'pretix.event.quota.deleted': _('The quota has been deleted.'), 'pretix.event.quota.changed': _('The quota has been modified.'), diff --git a/src/pretix/control/templates/pretixcontrol/item/addons.html b/src/pretix/control/templates/pretixcontrol/item/addons.html new file mode 100644 index 000000000..5e4a33deb --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/item/addons.html @@ -0,0 +1,86 @@ +{% extends "pretixcontrol/item/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load formset_tags %} +{% block inside %} +

+ {% blocktrans trimmed %} + With add-ons, you can specify products that can be bought as an addition to this product. For example, if + you host a conference with a base conference ticket and a number of workshops, you could define the + workshops as add-ons to the conference ticket. With this configuration, the workshops cannot be bought + on their own but only in combination with a conference ticket. You can here specify categories of products + that can be used as add-ons to this product. You can also specify the minimum and maximum number of + add-ons of the given category that can or need to be chosen. The user can buy every add-on from the + category at most once. If an add-on product has multiple variations, only one of them can be bought. + {% endblocktrans %} +

+
+ {% csrf_token %} +
+ {{ formset.management_form }} + {% bootstrap_formset_errors formset %} +
+ {% for form in formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+
+
+

{% trans "Add-On" %}

+
+
+ +
+
+
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.addon_category layout='horizontal' %} + {% bootstrap_field form.min_count layout='horizontal' %} + {% bootstrap_field form.max_count layout='horizontal' %} +
+
+ {% endfor %} +
+ +

+ +

+
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/item/base.html b/src/pretix/control/templates/pretixcontrol/item/base.html index 53fcc82c4..048398b59 100644 --- a/src/pretix/control/templates/pretixcontrol/item/base.html +++ b/src/pretix/control/templates/pretixcontrol/item/base.html @@ -4,20 +4,25 @@ {% block content %} {% if object.id %}

{% trans "Modify product:" %} {{ object.name }}

- {% if object.has_variations %} - {% endif %} {% else %}

{% trans "Create product" %}

{% blocktrans trimmed %} @@ -26,12 +31,12 @@ {% endif %} {% if object.id and not object.quotas.exists %}

- {% blocktrans trimmed %} + {% blocktrans trimmed %} Please note that your product will not be available for sale until you have added your item to an existing or newly created quota. - {% endblocktrans %} + {% endblocktrans %}
{% endif %} - {% block inside %} - {% endblock %} + {% block inside %} + {% endblock %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/items/category.html b/src/pretix/control/templates/pretixcontrol/items/category.html index a200b8029..b19c67f68 100644 --- a/src/pretix/control/templates/pretixcontrol/items/category.html +++ b/src/pretix/control/templates/pretixcontrol/items/category.html @@ -13,6 +13,7 @@ {% trans "General information" %} {% bootstrap_field form.name layout="horizontal" %} {% bootstrap_field form.description layout="horizontal" %} + {% bootstrap_field form.is_addon layout="horizontal" %} {% if category %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 6069bc53a..9443585a5 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -61,6 +61,8 @@ urlpatterns = [ url(r'^items/(?P\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), url(r'^items/(?P\d+)/variations$', item.ItemVariations.as_view(), name='event.item.variations'), + url(r'^items/(?P\d+)/addons', item.ItemAddOns.as_view(), + name='event.item.addons'), url(r'^items/(?P\d+)/up$', item.item_move_up, name='event.items.up'), url(r'^items/(?P\d+)/down$', item.item_move_down, name='event.items.down'), url(r'^items/(?P\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'), diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 6b692e378..d1c8b4269 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -21,9 +21,11 @@ from pretix.base.models import ( CachedTicket, Item, ItemCategory, ItemVariation, Order, Question, QuestionAnswer, QuestionOption, Quota, Voucher, ) +from pretix.base.models.items import ItemAddOn from pretix.control.forms.item import ( - CategoryForm, ItemCreateForm, ItemUpdateForm, ItemVariationForm, - ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm, + CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemCreateForm, + ItemUpdateForm, ItemVariationForm, ItemVariationsFormSet, QuestionForm, + QuestionOptionForm, QuotaForm, ) from pretix.control.permissions import ( EventPermissionRequiredMixin, event_permission_required, @@ -927,6 +929,74 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView return context +class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView): + permission = 'can_change_items' + template_name = 'pretixcontrol/item/addons.html' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.item = None + + @cached_property + def formset(self): + formsetclass = inlineformset_factory( + Item, ItemAddOn, + form=ItemAddOnForm, formset=ItemAddOnsFormSet, + can_order=False, can_delete=True, extra=0 + ) + return formsetclass(self.request.POST if self.request.method == "POST" else None, + queryset=ItemAddOn.objects.filter(base_item=self.get_object()), + event=self.request.event) + + 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.addons.removed', user=self.request.user, data={ + 'category': form.instance.addon_category.pk + } + ) + form.instance.delete() + form.instance.pk = None + + forms = [ + ef for ef in self.formset.extra_forms + self.formset.initial_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.addons.changed' if not created else + 'pretix.event.item.addons.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_success_url(self) -> str: + return reverse('control:event.item.addons', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + 'item': self.get_object().id, + }) + + def get_context_data(self, **kwargs) -> dict: + self.object = self.get_object() + context = super().get_context_data(**kwargs) + context['formset'] = self.formset + return context + + class ItemDelete(EventPermissionRequiredMixin, DeleteView): model = Item template_name = 'pretixcontrol/item/delete.html' diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 32a4f44ca..416a580c0 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -11,14 +11,17 @@ from django.views.generic.base import TemplateResponseMixin from pretix.base.models import Order from pretix.base.models.orders import InvoiceAddress +from pretix.base.services.cart import set_cart_addons from pretix.base.services.orders import perform_order from pretix.base.signals import register_payment_providers from pretix.multidomain.urlreverse import eventreverse -from pretix.presale.forms.checkout import ContactForm, InvoiceAddressForm +from pretix.presale.forms.checkout import ( + AddOnsForm, ContactForm, InvoiceAddressForm, +) from pretix.presale.signals import ( checkout_confirm_messages, checkout_flow_steps, order_meta_from_request, ) -from pretix.presale.views import CartMixin, get_cart_total +from pretix.presale.views import CartMixin, get_cart, get_cart_total from pretix.presale.views.async import AsyncAction from pretix.presale.views.questions import QuestionsViewMixin @@ -126,6 +129,116 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep): raise NotImplementedError() +class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): + priority = 40 + identifier = "addons" + template_name = "pretixpresale/event/checkout_addons.html" + task = set_cart_addons + known_errortypes = ['CartError'] + + def is_applicable(self, request): + return get_cart(request).filter(item__addons__isnull=False).exists() + + def is_completed(self, request, warn=False): + for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related( + 'item__addons', 'item__addons__addon_category', 'addons', 'addons__item' + ): + a = cartpos.addons.all() + for iao in cartpos.item.addons.all(): + found = len([1 for p in a if p.item.category_id == iao.addon_category_id]) + if found < iao.min_count or found > iao.max_count: + return False + return True + + @cached_property + def forms(self): + """ + A list of forms with one form for each cart position that has questions + the user can answer. All forms have a custom prefix, so that they can all be + submitted at once. + """ + formset = [] + for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related( + 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation' + ): + current_addon_products = { + a.item_id: a.variation_id for a in cartpos.addons.all() + } + formsetentry = { + 'cartpos': cartpos, + 'item': cartpos.item, + 'variation': cartpos.variation, + 'categories': [] + } + for iao in cartpos.item.addons.all(): + category = { + 'category': iao.addon_category, + 'min_count': iao.min_count, + 'max_count': iao.max_count, + 'form': AddOnsForm( + event=self.request.event, + prefix='{}_{}'.format(cartpos.pk, iao.addon_category.pk), + category=iao.addon_category, + initial=current_addon_products, + data=(self.request.POST if self.request.method == 'POST' else None) + ) + } + + if len(category['form'].fields) > 0: + formsetentry['categories'].append(category) + + formset.append(formsetentry) + return formset + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['forms'] = self.forms + return ctx + + def get_success_message(self, value): + return None + + def get_success_url(self, value): + return self.get_next_url(self.request) + + def get_error_url(self): + return self.get_step_url() + + def get(self, request): + self.request = request + if 'async_id' in request.GET and settings.HAS_CELERY: + return self.get_result(request) + return TemplateFlowStep.get(self, request) + + def post(self, request, *args, **kwargs): + self.request = request + is_valid = True + data = [] + for f in self.forms: + for c in f['categories']: + is_valid = is_valid and c['form'].is_valid() + if c['form'].is_valid(): + for k, v in c['form'].cleaned_data.items(): + itemid = int(k[5:]) + if v is True: + data.append({ + 'addon_to': f['cartpos'].pk, + 'item': itemid, + 'variation': None + }) + elif v: + data.append({ + 'addon_to': f['cartpos'].pk, + 'item': itemid, + 'variation': int(v) + }) + + if not is_valid: + return self.get(request, *args, **kwargs) + + return self.do(self.request.event.id, data, self.request.session.session_key) + + class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): priority = 50 identifier = "questions" @@ -395,6 +508,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): DEFAULT_FLOW = ( + AddOnsStep, QuestionsStep, PaymentStep, ConfirmStep diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 44f899a5a..33db2edd2 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -2,9 +2,12 @@ from decimal import Decimal from django import forms from django.core.exceptions import ValidationError +from django.db.models import Count, Prefetch, Q +from django.utils.formats import number_format +from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ -from pretix.base.models import Question +from pretix.base.models import ItemVariation, Question from pretix.base.models.orders import InvoiceAddress @@ -141,3 +144,90 @@ class QuestionsForm(forms.Form): # Cache the answer object for later use field.answer = answers[0] self.fields['question_%s' % q.id] = field + + +class AddOnsForm(forms.Form): + """ + This form class is responsible for selecting add-ons to a product in the cart. + """ + + def _label(self, event, item_or_variation): + if isinstance(item_or_variation, ItemVariation): + variation = item_or_variation + item = item_or_variation.item + price = variation.price + price_net = variation.net_price + label = variation.value + else: + item = item_or_variation + price = item.default_price + price_net = item.default_price_net + label = item.name + + if not item.tax_rate or not price: + return '{name} (+ {currency} {price})'.format( + name=label, currency=event.currency, price=number_format(price) + ) + elif event.settings.display_net_prices: + return '{name} (+ {currency} {price} plus {taxes}% taxes)'.format( + name=label, currency=event.currency, price=number_format(price_net), + taxes=number_format(item.tax_rate) + ) + else: + return '{name} (+ {currency} {price} incl. {taxes}% taxes)'.format( + name=label, currency=event.currency, price=number_format(price), + taxes=number_format(item.tax_rate) + ) + + def __init__(self, *args, **kwargs): + """ + Takes additional keyword arguments: + + :param category: The category to choose from + :param event: The event this belongs to + """ + category = kwargs.pop('category') + event = kwargs.pop('event') + current_addons = kwargs.pop('initial') + + super().__init__(*args, **kwargs) + + items = category.items.filter( + 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(hide_without_voucher=False) + ).prefetch_related( + 'variations__quotas', # for .availability() + Prefetch('quotas', queryset=event.quotas.all()), + Prefetch('variations', to_attr='available_variations', + queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()), + ).annotate( + quotac=Count('quotas'), + has_variations=Count('variations') + ).filter( + quotac__gt=0 + ).order_by('category__position', 'category_id', 'position', 'name') + + for i in items: + if i.has_variations: + field = forms.ChoiceField( + choices=[('', '–')] + [ + ( + v.pk, + self._label(event, v) + ) for v in i.available_variations + ], + label=i.name, + required=False, + widget=forms.RadioSelect, + initial=current_addons.get(i.pk) + ) + else: + field = forms.BooleanField( + label=self._label(event, i), + required=False, + initial=i.pk in current_addons + ) + + self.fields['item_%s' % i.pk] = field diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html new file mode 100644 index 000000000..ecf221c8f --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html @@ -0,0 +1,70 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Checkout" %}{% endblock %} +{% block content %} +

{% trans "Checkout" %}

+

+ {% trans "For some of the products in your cart, you can choose additional options before you continue." %} +

+
+ {% csrf_token %} +
+ {% for form in forms %} +
+ + +
+ {% endfor %} +
+
+ +
+ +
+
+
+
+{% endblock %}