Allow selecting the same add-on multiple times (#1717)

This commit is contained in:
Raphael Michel
2020-07-20 10:21:12 +02:00
committed by GitHub
parent ed3542e219
commit 3c5948d2e0
19 changed files with 743 additions and 335 deletions

View File

@@ -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)

View File

@@ -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),
),
]

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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 '

View File

@@ -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" %}
</div>
</div>
@@ -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" %}
</div>
</div>

View File

@@ -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(),

View File

@@ -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 += '<br>'
n += '<a href="{}" class="productpicture" data-title="{}" data-lightbox={}>'.format(
item.picture.url, escape(escape(item.name)), item.id
)
n += '<img src="{}" alt="{}">'.format(
thumb(item.picture, '60x60^'),
escape(item.name)
)
n += '</a>'
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 += '<br>'
n += '<a href="{}" class="productpicture" data-title="{}" data-lightbox="{}">'.format(
i.picture.url, escape(escape(i.name)), i.id
)
n += '<img src="{}" alt="{}">'.format(
thumb(i.picture, '60x60^'),
escape(i.name)
)
n += '</a>'
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))

View File

@@ -2,6 +2,9 @@
{% load i18n %}
{% load bootstrap3 %}
{% load rich_text %}
{% load l10n %}
{% load money %}
{% load thumb %}
{% block inner %}
<p>
{% trans "For some of the products in your cart, you can choose additional options before you continue." %}
@@ -9,7 +12,7 @@
<form class="form-horizontal" method="post" data-asynctask
data-asynctask-headline="{% trans "We're now trying to book these add-ons for you!" %}">
{% csrf_token %}
<div class="panel-group" id="questions_group">
<div class="panel-group addons" id="questions_group">
{% for form in forms %}
<details class="panel panel-default" open>
<summary class="panel-heading">
@@ -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 %}
</p>
{% bootstrap_form c.form layout="horizontal" %}
{% for item in c.items %}
{% if item.has_variations %}
<details class="item-with-variations" {% if event.settings.show_variations_expanded or item.expand %}open{% endif %}>
<summary class="row-fluid product-row headline">
<div class="col-md-8 col-xs-12">
{% if item.picture %}
<a href="{{ item.picture.url }}" class="productpicture"
data-title="{{ item.name|force_escape|force_escape }}"
{# Yes, double-escape to prevent XSS in lightbox #}
data-lightbox="{{ item.id }}">
<img src="{{ item.picture|thumb:'60x60^' }}"
alt="{{ item.name }}"/>
</a>
{% endif %}
<div class="product-description {% if item.picture %}with-picture{% endif %}">
<h4>
<a href="#" data-toggle="variations">
{{ item.name }}
</a>
</h4>
{% if item.description %}
<div class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% endif %}
{% if item.min_per_order and item.min_per_order > 1 %}
<p>
<small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small>
</p>
{% endif %}
</div>
</div>
<div class="col-md-2 col-xs-6 price">
{% 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 %}
</div>
<div class="col-md-2 col-xs-6 availability-box">
{% if not event.settings.show_variations_expanded %}
<a href="#" data-toggle="variations" class="js-only">
{% trans "Show variants" %}
</a>
{% endif %}
</div>
<div class="clearfix"></div>
</summary>
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}">
{% for var in item.available_variations %}
<div class="row-fluid product-row variation">
<div class="col-md-8 col-xs-12">
<h5>
<label for="cp_{{ form.cartpos.pk }}_variation_{{ item.pk }}_{{ var.pk }}">
{{ var }}
</label>
</h5>
{% if var.description %}
<div class="variation-description">
{{ var.description|localize|rich_text }}
</div>
{% endif %}
{% if item.do_show_quota_left %}
{% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %}
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% if not c.price_included %}
{% if var.original_price %}
{% if event.settings.display_net_prices %}
<del>{{ var.original_price.net|money:event.currency }}</del>
{% else %}
<del>{{ var.original_price.gross|money:event.currency }}</del>
{% endif %}
<ins>
{% endif %}
{% if item.free_price %}
<div class="input-group input-group-price">
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price"
placeholder="0"
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|money_numberfield:event.currency }}{% else %}{{ var.display_price.gross|money_numberfield:event.currency }}{% endif %}"
name="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}_price"
title="{% blocktrans trimmed with item=var.value %}Modify price for {{ item }}{% endblocktrans %}"
step="any"
value="{% if event.settings.display_net_prices %}{{ var.initial_price.net|money_numberfield:event.currency }}{% else %}{{ var.initial_price.gross|money_numberfield:event.currency }}{% endif %}"
>
</div>
{% 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 %}
</ins>
{% endif %}
{% if item.includes_mixed_tax_rate %}
{% if event.settings.display_net_prices %}
<small>{% trans "plus taxes" %}</small>
{% else %}
<small>{% trans "incl. taxes" %}</small>
{% endif %}
{% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% elif var.display_price.rate and var.display_price.gross %}
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
incl. {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% endif %}
{% endif %}
</div>
{% if var.cached_availability.0 == 100 or var.initial %}
<div class="col-md-2 col-xs-6 availability-box available">
{% if c.max_count == 1 or not c.multi_allowed %}
<label class="item-checkbox-label">
<input type="checkbox" value="1"
{% if var.initial %}checked="checked"{% endif %}
id="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}"
name="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}"
data-exclusive-prefix="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_"
title="{% blocktrans with item=item.name var=var.name %}Amount of {{ item }} {{ var }} to order{% endblocktrans %}">
</label>
{% else %}
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
{% if var.initial %}value="{{ var.initial }}"{% endif %}
max="{{ c.max_count }}"
pattern="\d*"
id="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}"
name="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}">
{% endif %}
</div>
{% 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 %}
<div class="clearfix"></div>
</div>
{% endfor %}
</div>
</details>
{% else %}
<div class="row-fluid product-row simple">
<div class="col-md-8 col-xs-12">
{% if item.picture %}
<a href="{{ item.picture.url }}" class="productpicture"
data-title="{{ item.name|force_escape|force_escape }}"
{# Yes, double-escape to prevent XSS in lightbox #}
data-lightbox="{{ item.id }}">
<img src="{{ item.picture|thumb:'60x60^' }}"
alt="{{ item.name }}"/>
</a>
{% endif %}
<div class="product-description {% if item.picture %}with-picture{% endif %}">
<h4>
<label for="cp_{{ form.cartpos.pk }}_item_{{ item.id }}">{{ item.name }}</label>
</h4>
{% if item.description %}
<div class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% 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 %}
<p>
<small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small>
</p>
{% endif %}
</div>
</div>
<div class="col-md-2 col-xs-6 price">
{% if not c.price_included %}
{% if item.original_price %}
{% if event.settings.display_net_prices %}
<del>{{ item.original_price.net|money:event.currency }}</del>
{% else %}
<del>{{ item.original_price.gross|money:event.currency }}</del>
{% endif %}
<ins>
{% endif %}
{% if item.free_price %}
<div class="input-group input-group-price">
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price" placeholder="0"
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|money_numberfield:event.currency }}{% else %}{{ item.display_price.gross|money_numberfield:event.currency }}{% endif %}"
name="cp_{{ form.cartpos.pk }}_item_{{ item.id }}_price"
title="{% blocktrans trimmed with item=item.name %}Modify price for {{ item }}{% endblocktrans %}"
value="{% if event.settings.display_net_prices %}{{ item.initial_price.net|money_numberfield:event.currency }}{% else %}{{ item.initial_price.gross|money_numberfield:event.currency }}{% endif %}"
step="any">
</div>
{% 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 %}
</ins>
{% endif %}
{% if item.includes_mixed_tax_rate %}
{% if event.settings.display_net_prices %}
<small>{% trans "plus taxes" %}</small>
{% else %}
<small>{% trans "incl. taxes" %}</small>
{% endif %}
{% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% elif item.display_price.rate and item.display_price.gross %}
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
incl. {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% endif %}
{% endif %}
</div>
{% if item.cached_availability.0 == 100 or item.initial %}
<div class="col-md-2 col-xs-6 availability-box available">
{% if c.max_count == 1 or not c.multi_allowed %}
<label class="item-checkbox-label">
<input type="checkbox" value="1"
{% if item.initial %}checked="checked"{% endif %}
name="cp_{{ form.cartpos.pk }}_item_{{ item.id }}"
id="cp_{{ form.cartpos.pk }}_item_{{ item.id }}">
</label>
{% else %}
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
pattern="\d*"
max="{{ c.max_count }}"
{% if item.initial %}value="{{ item.initial }}"{% endif %}
name="cp_{{ form.cartpos.pk }}_item_{{ item.id }}"
id="cp_{{ form.cartpos.pk }}_item_{{ item.id }}"
title="{% blocktrans with item=item.name %}Amount of {{ item }} to order{% endblocktrans %}">
{% endif %}
</div>
{% 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 %}
<div class="clearfix"></div>
</div>
{% endif %}
{% endfor %}
</fieldset>
{% empty %}
<em>

View File

@@ -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]

View File

@@ -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();
};

View File

@@ -87,3 +87,12 @@
display: none;
}
}
.addons {
fieldset {
margin-top: 20px;
&:first-child {
margin-top: 0;
}
}
}

View File

@@ -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 {
}

View File

@@ -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'

View File

@@ -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(

View File

@@ -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():