diff --git a/doc/api/resources/item_add-ons.rst b/doc/api/resources/item_add-ons.rst index b2494e132..2e3078eb8 100644 --- a/doc/api/resources/item_add-ons.rst +++ b/doc/api/resources/item_add-ons.rst @@ -24,6 +24,7 @@ addon_category integer Internal ID of min_count integer The minimal number of add-ons that need to be chosen. max_count integer The maximal number of add-ons that can be chosen. position integer An integer, used for sorting +multi_allowed boolean Adding the same item multiple times is allowed price_included boolean Adding this add-on to the item is free ===================================== ========================== ======================================================= @@ -65,6 +66,7 @@ Endpoints "min_count": 0, "max_count": 10, "position": 0, + "multi_allowed": false, "price_included": true }, { @@ -73,6 +75,7 @@ Endpoints "min_count": 0, "max_count": 10, "position": 1, + "multi_allowed": false, "price_included": true } ] @@ -112,6 +115,7 @@ Endpoints "min_count": 0, "max_count": 10, "position": 1, + "multi_allowed": false, "price_included": true } @@ -141,6 +145,7 @@ Endpoints "min_count": 0, "max_count": 10, "position": 1, + "multi_allowed": false, "price_included": true } @@ -158,6 +163,7 @@ Endpoints "min_count": 0, "max_count": 10, "position": 1, + "multi_allowed": false, "price_included": true } @@ -206,6 +212,7 @@ Endpoints "min_count": 0, "max_count": 10, "position": 1, + "multi_allowed": false, "price_included": true } diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 8b9cfbad7..1ee80386b 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -104,6 +104,7 @@ addons list of objects Definition of a ├ min_count integer The minimal number of add-ons that need to be chosen. ├ max_count integer The maximal number of add-ons that can be chosen. ├ position integer An integer, used for sorting +├ multi_allowed boolean Adding the same item multiple times is allowed └ price_included boolean Adding this add-on to the item is free bundles list of objects Definition of bundles that are included in this item. Only writable during creation, @@ -159,6 +160,10 @@ meta_data object Values set for The attribute ``meta_data`` has been added. +.. versionchanged:: 3.10 + + The attribute ``multi_allowed`` has been added to ``addons``. + Notes ----- diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 8924a0296..f48f12031 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -45,7 +45,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer): class Meta: model = ItemAddOn fields = ('addon_category', 'min_count', 'max_count', - 'position', 'price_included') + 'position', 'price_included', 'multi_allowed') class ItemBundleSerializer(serializers.ModelSerializer): @@ -77,7 +77,7 @@ class ItemAddOnSerializer(serializers.ModelSerializer): class Meta: model = ItemAddOn fields = ('id', 'addon_category', 'min_count', 'max_count', - 'position', 'price_included') + 'position', 'price_included', 'multi_allowed') def validate(self, data): data = super().validate(data) diff --git a/src/pretix/base/migrations/0157_auto_20200712_0932.py b/src/pretix/base/migrations/0157_auto_20200712_0932.py new file mode 100644 index 000000000..adeecee28 --- /dev/null +++ b/src/pretix/base/migrations/0157_auto_20200712_0932.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.6 on 2020-07-12 09:32 + +from django.db import migrations, models + +import pretix.helpers.countries + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0156_cartposition_override_tax_rate'), + ] + + operations = [ + migrations.AddField( + model_name='itemaddon', + name='multi_allowed', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index b76573c4e..bbb67525a 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -840,6 +840,10 @@ class ItemAddOn(models.Model): help_text=_('If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost ' 'money individually.') ) + multi_allowed = models.BooleanField( + default=False, + verbose_name=_('Allow the same product to be selected multiple times'), + ) position = models.PositiveIntegerField( default=0, verbose_name=_("Position") diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 87bdb641c..8634a8d29 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -96,6 +96,7 @@ error_messages = { '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.'), + 'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'), 'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'), 'bundled_only': _('One of the products you selected can only be bought part of a bundle.'), 'seat_required': _('You need to select a specific seat.'), @@ -605,9 +606,9 @@ class CartManager: ) # Prepare various containers to hold data later - current_addons = defaultdict(dict) # CartPos -> currently attached add-ons - input_addons = defaultdict(set) # CartPos -> add-ons according to input - selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons + current_addons = defaultdict(lambda: defaultdict(list)) # CartPos -> currently attached add-ons + input_addons = defaultdict(Counter) # CartPos -> final desired set of add-ons + selected_addons = defaultdict(Counter) # CartPos, ItemAddOn -> final desired set of add-ons cpcache = {} # CartPos.pk -> CartPos quota_diff = Counter() # Quota -> Number of usages operations = [] @@ -624,11 +625,9 @@ class CartManager: available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()} price_included[cp.pk] = {iao.addon_category_id: iao.price_included 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() - if not a.is_bundled - } + for a in cp.addons.all(): + if not a.is_bundled: + current_addons[cp][a.item_id, a.variation_id].append(a) # Create operations, perform various checks for a in addons: @@ -655,25 +654,31 @@ class CartManager: if not quotas: raise CartError(error_messages['unavailable']) - # Every item can be attached to very CartPosition at most once - if a['item'] in ([_a[0] for _a in input_addons[cp.id]]): + if (a['item'], a['variation']) 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'])) + input_addons[cp.id][a['item'], a['variation']] = a.get('count', 1) + selected_addons[cp.id, item.category_id][a['item'], a['variation']] = a.get('count', 1) - if (a['item'], a['variation']) not in current_addons[cp]: + if price_included[cp.pk].get(item.category_id): + price = TAXED_ZERO + else: + price = self._get_price(item, variation, None, a.get('price'), cp.subevent) + + # Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky) + for ca in current_addons[cp][a['item'], a['variation']]: + if ca.price != price.gross: + ca.price = price.gross + ca.save(update_fields=['price']) + + if a.get('count', 1) > len(current_addons[cp][a['item'], a['variation']]): # This add-on is new, add it to the cart for quota in quotas: - quota_diff[quota] += 1 - - if price_included[cp.pk].get(item.category_id): - price = TAXED_ZERO - else: - price = self._get_price(item, variation, None, None, cp.subevent) + quota_diff[quota] += a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]) op = self.AddOperation( - count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, + count=a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]), + item=item, variation=variation, price=price, voucher=None, quotas=quotas, addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None, price_before_voucher=None ) @@ -685,7 +690,10 @@ class CartManager: item = cp.item for iao in item.addons.all(): selected = selected_addons[cp.id, iao.addon_category_id] - if len(selected) > iao.max_count: + n_per_i = Counter() + for (i, v), c in selected.items(): + n_per_i[i] += c + if sum(selected.values()) > iao.max_count: # TODO: Proper i18n # TODO: Proper pluralization raise CartError( @@ -696,7 +704,7 @@ class CartManager: 'cat': str(iao.addon_category.name), } ) - elif len(selected) < iao.min_count: + elif sum(selected.values()) < iao.min_count: # TODO: Proper i18n # TODO: Proper pluralization raise CartError( @@ -707,28 +715,39 @@ class CartManager: 'cat': str(iao.addon_category.name), } ) + elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed: + raise CartError( + error_messages['addon_no_multi'], + { + 'base': str(item.name), + 'cat': str(iao.addon_category.name), + } + ) validate_cart_addons.send( sender=self.event, addons={ - (self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None) - for s in selected + (self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None): c + for s, c in selected.items() if c > 0 }, base_position=cp, iao=iao ) # Detect removed add-ons and create RemoveOperations - for cp, al in current_addons.items(): + for cp, al in list(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(v.quotas) + input_num = input_addons[cp.id].get(k, 0) + current_num = len(current_addons[cp].get(k, [])) + if input_num < current_num: + for a in current_addons[cp][k][:current_num - input_num]: + if a.expires > self.now_dt: + quotas = list(a.quotas) - for quota in quotas: - quota_diff[quota] -= 1 + for quota in quotas: + quota_diff[quota] -= 1 - op = self.RemoveOperation(position=v) - operations.append(op) + op = self.RemoveOperation(position=a) + operations.append(op) self._quota_diff.update(quota_diff) self._operations += operations diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 858789b27..9ae3d2697 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -309,7 +309,7 @@ validate_cart_addons = EventPluginSignal( """ This signal is sent when a user tries to select a combination of addons. In contrast to ``validate_cart``, this is executed before the cart is actually modified. You are passed -an argument ``addons`` containing a set of ``(item, variation or None)`` tuples as well +an argument ``addons`` containing a dict of ``(item, variation or None) → count`` tuples as well as the ``ItemAddOn`` object as the argument ``iao`` and the base cart position as ``base_position``. The response of receivers will be ignored, but you can raise a CartError with an diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index fecf1b42f..82b3da9cd 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -661,7 +661,8 @@ class ItemAddOnForm(I18nModelForm): 'addon_category', 'min_count', 'max_count', - 'price_included' + 'price_included', + 'multi_allowed', ] help_texts = { 'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all ' diff --git a/src/pretix/control/templates/pretixcontrol/item/include_addons.html b/src/pretix/control/templates/pretixcontrol/item/include_addons.html index 9afa0bcef..9cf9df065 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_addons.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_addons.html @@ -43,6 +43,7 @@ {% bootstrap_field form.addon_category layout="control" %} {% bootstrap_field form.min_count layout="control" %} {% bootstrap_field form.max_count layout="control" %} + {% bootstrap_field form.multi_allowed layout="control" %} {% bootstrap_field form.price_included layout="control" %} @@ -75,6 +76,7 @@ {% bootstrap_field formset.empty_form.addon_category layout="control" %} {% bootstrap_field formset.empty_form.min_count layout="control" %} {% bootstrap_field formset.empty_form.max_count layout="control" %} + {% bootstrap_field formset.empty_form.multi_allowed layout="control" %} {% bootstrap_field formset.empty_form.price_included layout="control" %} diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 720296636..5d77533cf 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1,4 +1,5 @@ import inspect +from collections import defaultdict from decimal import Decimal from django.conf import settings @@ -17,15 +18,17 @@ from django_scopes import scopes_disabled from pretix.base.models import Order from pretix.base.models.orders import InvoiceAddress, OrderPayment +from pretix.base.models.tax import TaxedPrice from pretix.base.services.cart import ( - get_fees, set_cart_addons, update_tax_rates, + CartError, error_messages, get_fees, set_cart_addons, update_tax_rates, ) from pretix.base.services.orders import perform_order +from pretix.base.signals import validate_cart_addons from pretix.base.templatetags.rich_text import rich_text_snippet from pretix.base.views.tasks import AsyncAction from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.checkout import ( - AddOnsForm, ContactForm, InvoiceAddressForm, InvoiceNameForm, + ContactForm, InvoiceAddressForm, InvoiceNameForm, ) from pretix.presale.signals import ( checkout_all_optional, checkout_confirm_messages, checkout_flow_steps, @@ -37,6 +40,7 @@ from pretix.presale.views import ( from pretix.presale.views.cart import ( cart_session, create_empty_cart_id, get_or_create_cart_id, ) +from pretix.presale.views.event import get_grouped_items from pretix.presale.views.questions import QuestionsViewMixin @@ -224,39 +228,79 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related( 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', ).order_by('pk'): - current_addon_products = { - a.item_id: a.variation_id for a in cartpos.addons.all() if not a.is_bundled - } 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), - base_position=cartpos, - iao=iao, - price_included=iao.price_included, - initial=current_addon_products, - data=(self.request.POST if self.request.method == 'POST' else None), - quota_cache=quota_cache, - item_cache=item_cache, - subevent=cartpos.subevent, - sales_channel=self.request.sales_channel.identifier - ) - } - - if len(category['form'].fields) > 0: - formsetentry['categories'].append(category) - formset.append(formsetentry) + + current_addon_products = defaultdict(list) + for a in cartpos.addons.all(): + if not a.is_bundled: + current_addon_products[a.item_id, a.variation_id].append(a) + + for iao in cartpos.item.addons.all(): + ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk) + + if ckey not in item_cache: + # Get all items to possibly show + items, _btn = get_grouped_items( + self.request.event, + subevent=cartpos.subevent, + voucher=None, + channel=self.request.sales_channel.identifier, + base_qs=iao.addon_category.items, + allow_addons=True, + quota_cache=quota_cache + ) + item_cache[ckey] = items + else: + items = item_cache[ckey] + + for i in items: + i.allow_waitinglist = False + + if i.has_variations: + for v in i.available_variations: + v.initial = len(current_addon_products[i.pk, v.pk]) + if v.initial and i.free_price: + a = current_addon_products[i.pk, v.pk][0] + v.initial_price = TaxedPrice( + net=a.price - a.tax_value, + gross=a.price, + tax=a.tax_value, + name=a.item.tax_rule.name, + rate=a.tax_rate, + ) + else: + v.initial_price = v.display_price + i.expand = any(v.initial for v in i.available_variations) + else: + i.initial = len(current_addon_products[i.pk, None]) + if i.initial and i.free_price: + a = current_addon_products[i.pk, None][0] + i.initial_price = TaxedPrice( + net=a.price - a.tax_value, + gross=a.price, + tax=a.tax_value, + name=a.item.tax_rule.name, + rate=a.tax_rate, + ) + else: + i.initial_price = i.display_price + + if items: + formsetentry['categories'].append({ + 'category': iao.addon_category, + 'price_included': iao.price_included, + 'multi_allowed': iao.multi_allowed, + 'min_count': iao.min_count, + 'max_count': iao.max_count, + 'iao': iao, + 'items': items + }) return formset def get_context_data(self, **kwargs): @@ -274,37 +318,89 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): def get_error_url(self): return self.get_step_url(self.request) - def get(self, request): + def get(self, request, **kwargs): self.request = request if 'async_id' in request.GET and settings.HAS_CELERY: return self.get_result(request) return TemplateFlowStep.get(self, request) + def _clean_category(self, form, category): + selected = {} + for i in category['items']: + if i.has_variations: + for v in i.available_variations: + val = int(self.request.POST.get(f'cp_{form["cartpos"].pk}_variation_{i.pk}_{v.pk}') or '0') + price = self.request.POST.get(f'cp_{form["cartpos"].pk}_variation_{i.pk}_{v.pk}_price') or '0' + if val: + selected[i, v] = val, price + else: + val = int(self.request.POST.get(f'cp_{form["cartpos"].pk}_item_{i.pk}') or '0') + price = self.request.POST.get(f'cp_{form["cartpos"].pk}_item_{i.pk}_price') or '0' + if val: + selected[i, None] = val, price + + if sum(a[0] for a in selected.values()) > category['max_count']: + # TODO: Proper pluralization + raise ValidationError( + _(error_messages['addon_max_count']), + 'addon_max_count', + { + 'base': str(form['item'].name), + 'max': category['max_count'], + 'cat': str(category['category'].name), + } + ) + elif sum(a[0] for a in selected.values()) < category['min_count']: + # TODO: Proper pluralization + raise ValidationError( + _(error_messages['addon_min_count']), + 'addon_min_count', + { + 'base': str(form['item'].name), + 'min': category['min_count'], + 'cat': str(category['category'].name), + } + ) + elif any(sum(v[0] for k, v in selected.items() if k[0] == i) > 1 for i in category['items']) and not category['multi_allowed']: + raise ValidationError( + _(error_messages['addon_no_multi']), + 'addon_no_multi', + { + 'base': str(form['item'].name), + 'cat': str(category['category'].name), + } + ) + try: + validate_cart_addons.send( + sender=self.event, + addons={k: v[0] for k, v in selected.items()}, + base_position=form["cartpos"], + iao=category['iao'] + ) + except CartError as e: + raise ValidationError(str(e)) + + return selected + 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) - }) + try: + selected = self._clean_category(f, c) + except ValidationError as e: + messages.error(request, e.message % e.params if e.params else e.message) + return self.get(request, *args, **kwargs) - if not is_valid: - return self.get(request, *args, **kwargs) + for (i, v), (c, price) in selected.items(): + data.append({ + 'addon_to': f['cartpos'].pk, + 'item': i.pk, + 'variation': v.pk if v else None, + 'count': c, + 'price': price, + }) return self.do(self.request.event.id, data, get_or_create_cart_id(self.request), invoice_address=self.invoice_address.pk, locale=get_language(), diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 9dc533de0..23600c944 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -2,24 +2,13 @@ from itertools import chain from django import forms from django.core.exceptions import ValidationError -from django.db.models import Count, Prefetch from django.utils.encoding import force_str -from django.utils.formats import number_format -from django.utils.html import escape -from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from pretix.base.forms.questions import ( BaseInvoiceAddressForm, BaseQuestionsForm, ) -from pretix.base.models import ItemVariation, Quota -from pretix.base.models.tax import TAXED_ZERO -from pretix.base.services.cart import CartError, error_messages -from pretix.base.signals import validate_cart_addons -from pretix.base.templatetags.money import money_filter -from pretix.base.templatetags.rich_text import rich_text from pretix.base.validators import EmailBanlistValidator -from pretix.helpers.templatetags.thumb import thumb from pretix.presale.signals import contact_form_fields @@ -126,230 +115,3 @@ class AddOnVariationField(forms.ChoiceField): if value == k or text_value == force_str(k): return True return False - - -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, avail, override_price=None, initial=False): - if isinstance(item_or_variation, ItemVariation): - variation = item_or_variation - item = item_or_variation.item - price = variation.price - label = variation.value - else: - item = item_or_variation - price = item.default_price - label = item.name - - if override_price: - price = override_price - - if self.price_included: - price = TAXED_ZERO - else: - price = item.tax(price) - - if not price.gross: - n = '{name}'.format( - name=label - ) - elif not price.rate: - n = _('{name} (+ {price})').format( - name=label, price=money_filter(price.gross, event.currency) - ) - elif event.settings.display_net_prices: - n = _('{name} (+ {price} plus {taxes}% {taxname})').format( - name=label, price=money_filter(price.net, event.currency), - taxes=number_format(price.rate), taxname=price.name - ) - else: - n = _('{name} (+ {price} incl. {taxes}% {taxname})').format( - name=label, price=money_filter(price.gross, event.currency), - taxes=number_format(price.rate), taxname=price.name - ) - - if not initial: - if avail[0] < Quota.AVAILABILITY_RESERVED: - n += ' – {}'.format(_('SOLD OUT')) - elif avail[0] < Quota.AVAILABILITY_OK: - n += ' – {}'.format(_('Currently unavailable')) - else: - if avail[1] is not None and item.do_show_quota_left: - n += ' – {}'.format(_('%(num)s currently available') % {'num': avail[1]}) - - if not isinstance(item_or_variation, ItemVariation) and item.picture: - n = escape(n) - n += '
' - n += ''.format( - item.picture.url, escape(escape(item.name)), item.id - ) - n += '{}'.format( - thumb(item.picture, '60x60^'), - escape(item.name) - ) - n += '' - n = mark_safe(n) - return n - - def __init__(self, *args, **kwargs): - """ - Takes additional keyword arguments: - - :param iao: The ItemAddOn object - :param event: The event this belongs to - :param subevent: The event the parent cart position belongs to - :param initial: The current set of add-ons - :param quota_cache: A shared dictionary for quota caching - :param item_cache: A shared dictionary for item/category caching - """ - self.iao = kwargs.pop('iao') - category = self.iao.addon_category - self.event = kwargs.pop('event') - subevent = kwargs.pop('subevent') - current_addons = kwargs.pop('initial') - quota_cache = kwargs.pop('quota_cache') - item_cache = kwargs.pop('item_cache') - self.price_included = kwargs.pop('price_included') - self.sales_channel = kwargs.pop('sales_channel') - self.base_position = kwargs.pop('base_position') - - super().__init__(*args, **kwargs) - - if subevent: - item_price_override = subevent.item_price_overrides - var_price_override = subevent.var_price_overrides - else: - item_price_override = {} - var_price_override = {} - - ckey = '{}-{}'.format(subevent.pk if subevent else 0, category.pk) - if ckey not in item_cache: - # Get all items to possibly show - items = category.items.filter_available( - channel=self.sales_channel, - allow_addons=True - ).select_related('tax_rule').prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=self.event.quotas.filter(subevent=subevent)), - Prefetch('variations', to_attr='available_variations', - queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=self.event.quotas.filter(subevent=subevent)) - ).distinct()), - 'event' - ).annotate( - quotac=Count('quotas'), - has_variations=Count('variations') - ).filter( - quotac__gt=0 - ).order_by('category__position', 'category_id', 'position', 'name') - item_cache[ckey] = items - else: - items = item_cache[ckey] - - self.vars_cache = {} - - for i in items: - if i.hidden_if_available: - q = i.hidden_if_available.availability(_cache=quota_cache) - if q[0] == Quota.AVAILABILITY_OK: - continue - - if i.has_variations: - choices = [('', _('no selection'), '')] - for v in i.available_variations: - cached_availability = v.check_quotas(subevent=subevent, _cache=quota_cache) - if self.event.settings.hide_sold_out and cached_availability[0] < Quota.AVAILABILITY_RESERVED: - continue - - if v._subevent_quotas: - self.vars_cache[v.pk] = v - choices.append( - (v.pk, - self._label(self.event, v, cached_availability, - override_price=var_price_override.get(v.pk), - initial=current_addons.get(i.pk) == v.pk), - v.description) - ) - - n = i.name - if i.picture: - n = escape(n) - n += '
' - n += ''.format( - i.picture.url, escape(escape(i.name)), i.id - ) - n += '{}'.format( - thumb(i.picture, '60x60^'), - escape(i.name) - ) - n += '' - n = mark_safe(n) - field = AddOnVariationField( - choices=choices, - label=n, - required=False, - widget=AddOnRadioSelect, - help_text=rich_text(str(i.description)), - initial=current_addons.get(i.pk), - ) - field.item = i - if len(choices) > 1: - self.fields['item_%s' % i.pk] = field - else: - if not i._subevent_quotas: - continue - cached_availability = i.check_quotas(subevent=subevent, _cache=quota_cache) - if self.event.settings.hide_sold_out and cached_availability[0] < Quota.AVAILABILITY_RESERVED: - continue - field = forms.BooleanField( - label=self._label(self.event, i, cached_availability, - override_price=item_price_override.get(i.pk), - initial=i.pk in current_addons), - required=False, - initial=i.pk in current_addons, - help_text=rich_text(str(i.description)), - ) - field.item = i - self.fields['item_%s' % i.pk] = field - - def clean(self): - data = super().clean() - selected = set() - for k, v in data.items(): - if v is True: - selected.add((self.fields[k].item, None)) - elif v: - selected.add((self.fields[k].item, self.vars_cache.get(int(v)))) - if len(selected) > self.iao.max_count: - # TODO: Proper pluralization - raise ValidationError( - _(error_messages['addon_max_count']), - 'addon_max_count', - { - 'base': str(self.iao.base_item.name), - 'max': self.iao.max_count, - 'cat': str(self.iao.addon_category.name), - } - ) - elif len(selected) < self.iao.min_count: - # TODO: Proper pluralization - raise ValidationError( - _(error_messages['addon_min_count']), - 'addon_min_count', - { - 'base': str(self.iao.base_item.name), - 'min': self.iao.min_count, - 'cat': str(self.iao.addon_category.name), - } - ) - try: - validate_cart_addons.send(sender=self.event, addons=selected, base_position=self.base_position, - iao=self.iao) - except CartError as e: - raise ValidationError(str(e)) diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html index d1d1b123f..2e7b43acd 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html @@ -2,6 +2,9 @@ {% load i18n %} {% load bootstrap3 %} {% load rich_text %} +{% load l10n %} +{% load money %} +{% load thumb %} {% block inner %}

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

{% csrf_token %} -
+
{% for form in forms %}
@@ -45,7 +48,11 @@ {% plural %} You need to choose {{ min_count }} options from this category. {% endblocktrans %} - {% elif c.min_count == 0 and c.max_count >= c.form.fields|length %} + {% elif c.min_count == 0 and c.max_count >= c.items|length and not c.multi_allowed %} + {% elif c.min_count == 0 %} + {% blocktrans trimmed with max_count=c.max_count %} + You can choose up to {{ max_count }} options from this category. + {% endblocktrans %} {% else %} {% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %} You can choose between {{ min_count }} and {{ max_count }} options from @@ -53,7 +60,265 @@ {% endblocktrans %} {% endif %}

- {% bootstrap_form c.form layout="horizontal" %} + {% for item in c.items %} + {% if item.has_variations %} +
+ +
+ {% if item.picture %} + + {{ item.name }} + + {% endif %} +
+

+ + {{ item.name }} + +

+ {% if item.description %} +
+ {{ item.description|localize|rich_text }} +
+ {% endif %} + {% if item.min_per_order and item.min_per_order > 1 %} +

+ + {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} + +

+ {% endif %} +
+
+
+ {% if c.price_included %} + {% elif item.free_price %} + {% blocktrans trimmed with price=item.min_price|money:event.currency %} + from {{ price }} + {% endblocktrans %} + {% elif item.min_price != item.max_price %} + {{ item.min_price|money:event.currency }} – {{ item.max_price|money:event.currency }} + {% elif not item.min_price and not item.max_price %} + {% else %} + {{ item.min_price|money:event.currency }} + {% endif %} +
+
+ {% if not event.settings.show_variations_expanded %} + + {% trans "Show variants" %} + + {% endif %} +
+
+
+
+ {% for var in item.available_variations %} +
+
+
+ +
+ {% if var.description %} +
+ {{ var.description|localize|rich_text }} +
+ {% endif %} + {% if item.do_show_quota_left %} + {% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %} + {% endif %} +
+
+ {% if not c.price_included %} + {% if var.original_price %} + {% if event.settings.display_net_prices %} + {{ var.original_price.net|money:event.currency }} + {% else %} + {{ var.original_price.gross|money:event.currency }} + {% endif %} + + {% endif %} + {% if item.free_price %} +
+ {{ event.currency }} + +
+ {% elif not var.display_price.gross %} + {% elif event.settings.display_net_prices %} + {{ var.display_price.net|money:event.currency }} + {% else %} + {{ var.display_price.gross|money:event.currency }} + {% endif %} + {% if item.original_price or var.original_price %} +
+ {% endif %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} + plus {{ rate }}% {{ name }} + {% endblocktrans %} + {% elif var.display_price.rate and var.display_price.gross %} + {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} + incl. {{ rate }}% {{ name }} + {% endblocktrans %} + {% endif %} + {% endif %} +
+ {% if var.cached_availability.0 == 100 or var.initial %} +
+ {% if c.max_count == 1 or not c.multi_allowed %} + + {% else %} + + {% endif %} +
+ {% else %} + {% include "pretixpresale/event/fragment_availability.html" with price=var.display_price.gross avail=var.cached_availability.0 event=event item=item var=var %} + {% endif %} +
+
+ {% endfor %} +
+
+ {% else %} +
+
+ {% if item.picture %} + + {{ item.name }} + + {% endif %} +
+

+ +

+ {% if item.description %} +
+ {{ item.description|localize|rich_text }} +
+ {% endif %} + {% if item.do_show_quota_left %} + {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} + {% endif %} + {% if item.min_per_order and item.min_per_order > 1 %} +

+ + {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} + +

+ {% endif %} +
+
+
+ {% if not c.price_included %} + {% if item.original_price %} + {% if event.settings.display_net_prices %} + {{ item.original_price.net|money:event.currency }} + {% else %} + {{ item.original_price.gross|money:event.currency }} + {% endif %} + + {% endif %} + {% if item.free_price %} +
+ {{ event.currency }} + +
+ {% elif not item.display_price.gross %} + {% elif event.settings.display_net_prices %} + {{ item.display_price.net|money:event.currency }} + {% else %} + {{ item.display_price.gross|money:event.currency }} + {% endif %} + {% if item.original_price %} +
+ {% endif %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} + plus {{ rate }}% {{ name }} + {% endblocktrans %} + {% elif item.display_price.rate and item.display_price.gross %} + {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} + incl. {{ rate }}% {{ name }} + {% endblocktrans %} + {% endif %} + {% endif %} +
+ {% if item.cached_availability.0 == 100 or item.initial %} +
+ {% if c.max_count == 1 or not c.multi_allowed %} + + {% else %} + + {% endif %} +
+ {% else %} + {% include "pretixpresale/event/fragment_availability.html" with price=item.display_price.gross avail=item.cached_availability.0 event=event item=item var=0 %} + {% endif %} +
+
+ {% endif %} + {% endfor %} {% empty %} diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index c3fefd0ff..977c12ac9 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -58,9 +58,10 @@ def item_group_by_category(items): ) -def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0, base_qs=None): +def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0, base_qs=None, allow_addons=False, + quota_cache=None): base_qs = base_qs if base_qs is not None else event.items - items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).select_related( + items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher, allow_addons=allow_addons).select_related( 'category', 'tax_rule', # for re-grouping 'hidden_if_available', ).prefetch_related( @@ -124,7 +125,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require else: items = items.filter(requires_seat=0) display_add_to_cart = False - external_quota_cache = event.cache.get('item_quota_cache') + external_quota_cache = quota_cache or event.cache.get('item_quota_cache') quota_cache = external_quota_cache or {} if subevent: @@ -291,7 +292,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require item._remove = not bool(item.available_variations) - if not external_quota_cache and not voucher: + if not external_quota_cache and not voucher and not allow_addons: event.cache.set('item_quota_cache', quota_cache, 5) items = [item for item in items if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove] diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index b1410c49a..42b945544 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -114,6 +114,15 @@ var form_handlers = function (el) { ); }); + el.find("input[data-exclusive-prefix]").each(function () { + var $others = $("input[name^=" + $(this).attr("data-exclusive-prefix") + "]:not([name=" + $(this).attr("name") + "])"); + $(this).on('click change', function () { + if ($(this).prop('checked')) { + $others.prop('checked', false); + } + }); + }); + el.find("input[name*=question], select[name*=question]").change(questions_toggle_dependent); questions_toggle_dependent(); }; diff --git a/src/pretix/static/pretixpresale/scss/_checkout.scss b/src/pretix/static/pretixpresale/scss/_checkout.scss index a139fabd1..3ceae65a4 100644 --- a/src/pretix/static/pretixpresale/scss/_checkout.scss +++ b/src/pretix/static/pretixpresale/scss/_checkout.scss @@ -87,3 +87,12 @@ display: none; } } + +.addons { + fieldset { + margin-top: 20px; + &:first-child { + margin-top: 0; + } + } +} diff --git a/src/pretix/static/pretixpresale/scss/_event.scss b/src/pretix/static/pretixpresale/scss/_event.scss index eaf1cae37..fb123c9ea 100644 --- a/src/pretix/static/pretixpresale/scss/_event.scss +++ b/src/pretix/static/pretixpresale/scss/_event.scss @@ -1,5 +1,11 @@ .product-row { border-top: 1px solid $table-border-color; + .addons &:first-child { + border-top: 2px solid $table-border-color; + } + .addons &:last-child { + border-bottom: 2px solid $table-border-color; + } &:last-child { } diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 0dccfddc4..025615c06 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -352,7 +352,8 @@ def test_item_detail_addons(token_client, organizer, event, team, item, category "min_count": 0, "max_count": 1, "position": 0, - "price_included": False + "multi_allowed": False, + "price_included": False, }] resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk)) @@ -575,6 +576,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, "min_count": 0, "max_count": 10, "position": 0, + "multi_allowed": False, "price_included": True } ] @@ -620,6 +622,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, "min_count": 0, "max_count": 10, "position": 0, + "multi_allowed": False, "price_included": True } ] @@ -663,6 +666,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, "min_count": 110, "max_count": 10, "position": 0, + "multi_allowed": False, "price_included": True } ] @@ -706,6 +710,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, "min_count": -1, "max_count": 10, "position": 0, + "multi_allowed": False, "price_included": True } ] @@ -920,6 +925,7 @@ def test_item_update(token_client, organizer, event, item, category, item2, cate "min_count": 0, "max_count": 10, "position": 0, + "multi_allowed": False, "price_included": True } ] @@ -1011,6 +1017,7 @@ def test_item_update_with_addon(token_client, organizer, event, item, category): "min_count": 0, "max_count": 10, "position": 0, + "multi_allowed": False, "price_included": True } ] @@ -1372,6 +1379,7 @@ TEST_ADDONS_RES = { "min_count": 0, "max_count": 10, "position": 1, + "multi_allowed": False, "price_included": False } @@ -1410,6 +1418,7 @@ def test_addons_create(token_client, organizer, event, item, category, category2 "min_count": 0, "max_count": 10, "position": 1, + "multi_allowed": False, "price_included": False }, format='json' @@ -1427,6 +1436,7 @@ def test_addons_create(token_client, organizer, event, item, category, category2 "min_count": 10, "max_count": 20, "position": 2, + "multi_allowed": False, "price_included": False }, format='json' @@ -1441,6 +1451,7 @@ def test_addons_create(token_client, organizer, event, item, category, category2 "min_count": 10, "max_count": 20, "position": 2, + "multi_allowed": False, "price_included": False }, format='json' diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index cefe75d85..10304cbe0 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -2222,6 +2222,158 @@ class CartAddonTest(CartTestMixin, TestCase): ]) self.cm.commit() + @classscope(attr='orga') + def test_multi_allowed(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.addon1.max_count = 2 + self.addon1.multi_allowed = True + self.addon1.save() + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk + }, + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3b.pk + } + ]) + self.cm.commit() + assert cp1.addons.count() == 2 + + @classscope(attr='orga') + def test_number_exceeds_max(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.addon1.max_count = 2 + self.addon1.multi_allowed = True + self.addon1.save() + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk, + 'count': 3, + }, + ]) + self.cm.commit() + assert cp1.addons.count() == 0 + + @classscope(attr='orga') + def test_number_exceeds_quota(self): + self.workshopquota.size = 1 + self.workshopquota.save() + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.addon1.max_count = 2 + self.addon1.multi_allowed = True + self.addon1.save() + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk, + 'count': 2, + }, + ]) + self.cm.commit() + assert cp1.addons.count() == 1 + + @classscope(attr='orga') + def test_free_price(self): + self.workshop3.free_price = True + self.workshop3.save() + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.addon1.max_count = 5 + self.addon1.multi_allowed = True + self.addon1.save() + + self.cm = CartManager(event=self.event, cart_id=self.session_key) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk, + 'count': 3, + 'price': '24.00' + }, + ]) + self.cm.commit() + assert cp1.addons.count() == 3 + assert all(a.price == Decimal('24.00') for a in cp1.addons.all()) + + self.cm = CartManager(event=self.event, cart_id=self.session_key) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk, + 'count': 3, + 'price': '5.00' + }, + ]) + self.cm.commit() + assert cp1.addons.count() == 3 + assert all(a.price == Decimal('12.00') for a in cp1.addons.all()) + + @classscope(attr='orga') + def test_change_number(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.addon1.max_count = 5 + self.addon1.multi_allowed = True + self.addon1.save() + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk, + 'count': 3, + }, + ]) + self.cm.commit() + assert cp1.addons.count() == 3 + + self.cm = CartManager(event=self.event, cart_id=self.session_key) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk, + 'count': 4, + }, + ]) + self.cm.commit() + assert cp1.addons.count() == 4 + + self.cm = CartManager(event=self.event, cart_id=self.session_key) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk, + 'count': 2, + }, + ]) + self.cm.commit() + assert cp1.addons.count() == 2 + @classscope(attr='orga') def test_no_duplicate_items_for_same_cp(self): cp1 = CartPosition.objects.create( diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 104a1f899..bd3bdb472 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -2226,8 +2226,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): ) response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), { - '{}_{}-item_{}'.format(cp1.pk, self.workshopcat.pk, self.workshop1.pk): 'on', - '{}_{}-item_{}'.format(cp2.pk, self.workshopcat.pk, self.workshop2.pk): self.workshop2a.pk, + 'cp_{}_item_{}'.format(cp1.pk, self.workshop1.pk): '1', + 'cp_{}_variation_{}_{}'.format(cp2.pk, self.workshop2.pk, self.workshop2a.pk): '1', }, follow=True) self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), target_status_code=200) @@ -2236,6 +2236,45 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): assert cp2.addons.first().item == self.workshop2 assert cp2.addons.first().variation == self.workshop2a + def test_set_addon_multi(self): + with scopes_disabled(): + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, multi_allowed=True, max_count=2) + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + + response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), { + 'cp_{}_item_{}'.format(cp1.pk, self.workshop1.pk): '2', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + with scopes_disabled(): + assert cp1.addons.count() == 2 + assert cp1.addons.first().item == self.workshop1 + assert cp1.addons.last().item == self.workshop1 + + def test_set_addon_free_price(self): + with scopes_disabled(): + self.workshop1.free_price = True + self.workshop1.save() + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat) + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + + response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), { + 'cp_{}_item_{}'.format(cp1.pk, self.workshop1.pk): '1', + 'cp_{}_item_{}_price'.format(cp1.pk, self.workshop1.pk): '999,99', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + with scopes_disabled(): + assert cp1.addons.count() == 1 + assert cp1.addons.first().item == self.workshop1 + assert cp1.addons.first().price == Decimal('999.99') + def test_set_addons_required(self): with scopes_disabled(): ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1) @@ -2332,7 +2371,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), target_status_code=200) - assert 'Workshop 1 (+ €42.00)' in response.rendered_content + assert '42.00' in response.rendered_content def test_set_addons_subevent_net_prices(self): with scopes_disabled(): @@ -2358,8 +2397,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), target_status_code=200) - assert 'Workshop 1 (+ €35.29 plus 19.00% VAT)' in response.rendered_content - assert 'A (+ €10.08 plus 19.00% VAT)' in response.rendered_content + assert '35.29' in response.rendered_content + assert '10.08' in response.rendered_content def test_confirm_subevent_presale_not_yet(self): with scopes_disabled():