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
+7
View File
@@ -24,6 +24,7 @@ addon_category integer Internal ID of
min_count integer The minimal number of add-ons that need to be chosen. 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. max_count integer The maximal number of add-ons that can be chosen.
position integer An integer, used for sorting 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 price_included boolean Adding this add-on to the item is free
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -65,6 +66,7 @@ Endpoints
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 0, "position": 0,
"multi_allowed": false,
"price_included": true "price_included": true
}, },
{ {
@@ -73,6 +75,7 @@ Endpoints
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 1, "position": 1,
"multi_allowed": false,
"price_included": true "price_included": true
} }
] ]
@@ -112,6 +115,7 @@ Endpoints
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 1, "position": 1,
"multi_allowed": false,
"price_included": true "price_included": true
} }
@@ -141,6 +145,7 @@ Endpoints
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 1, "position": 1,
"multi_allowed": false,
"price_included": true "price_included": true
} }
@@ -158,6 +163,7 @@ Endpoints
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 1, "position": 1,
"multi_allowed": false,
"price_included": true "price_included": true
} }
@@ -206,6 +212,7 @@ Endpoints
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 1, "position": 1,
"multi_allowed": false,
"price_included": true "price_included": true
} }
+5
View File
@@ -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. ├ 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. ├ max_count integer The maximal number of add-ons that can be chosen.
├ position integer An integer, used for sorting ├ 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 └ 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. bundles list of objects Definition of bundles that are included in this item.
Only writable during creation, Only writable during creation,
@@ -159,6 +160,10 @@ meta_data object Values set for
The attribute ``meta_data`` has been added. The attribute ``meta_data`` has been added.
.. versionchanged:: 3.10
The attribute ``multi_allowed`` has been added to ``addons``.
Notes Notes
----- -----
+2 -2
View File
@@ -45,7 +45,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemAddOn model = ItemAddOn
fields = ('addon_category', 'min_count', 'max_count', fields = ('addon_category', 'min_count', 'max_count',
'position', 'price_included') 'position', 'price_included', 'multi_allowed')
class ItemBundleSerializer(serializers.ModelSerializer): class ItemBundleSerializer(serializers.ModelSerializer):
@@ -77,7 +77,7 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemAddOn model = ItemAddOn
fields = ('id', 'addon_category', 'min_count', 'max_count', fields = ('id', 'addon_category', 'min_count', 'max_count',
'position', 'price_included') 'position', 'price_included', 'multi_allowed')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -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),
),
]
+4
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 ' help_text=_('If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost '
'money individually.') 'money individually.')
) )
multi_allowed = models.BooleanField(
default=False,
verbose_name=_('Allow the same product to be selected multiple times'),
)
position = models.PositiveIntegerField( position = models.PositiveIntegerField(
default=0, default=0,
verbose_name=_("Position") verbose_name=_("Position")
+51 -32
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_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 ' 'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
'product %(base)s.'), '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.'), '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.'), '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.'), 'seat_required': _('You need to select a specific seat.'),
@@ -605,9 +606,9 @@ class CartManager:
) )
# Prepare various containers to hold data later # Prepare various containers to hold data later
current_addons = defaultdict(dict) # CartPos -> currently attached add-ons current_addons = defaultdict(lambda: defaultdict(list)) # CartPos -> currently attached add-ons
input_addons = defaultdict(set) # CartPos -> add-ons according to input input_addons = defaultdict(Counter) # CartPos -> final desired set of add-ons
selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons selected_addons = defaultdict(Counter) # CartPos, ItemAddOn -> final desired set of add-ons
cpcache = {} # CartPos.pk -> CartPos cpcache = {} # CartPos.pk -> CartPos
quota_diff = Counter() # Quota -> Number of usages quota_diff = Counter() # Quota -> Number of usages
operations = [] operations = []
@@ -624,11 +625,9 @@ class CartManager:
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()} 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()} price_included[cp.pk] = {iao.addon_category_id: iao.price_included for iao in cp.item.addons.all()}
cpcache[cp.pk] = cp cpcache[cp.pk] = cp
current_addons[cp] = { for a in cp.addons.all():
(a.item_id, a.variation_id): a if not a.is_bundled:
for a in cp.addons.all() current_addons[cp][a.item_id, a.variation_id].append(a)
if not a.is_bundled
}
# Create operations, perform various checks # Create operations, perform various checks
for a in addons: for a in addons:
@@ -655,25 +654,31 @@ class CartManager:
if not quotas: if not quotas:
raise CartError(error_messages['unavailable']) raise CartError(error_messages['unavailable'])
# Every item can be attached to very CartPosition at most once if (a['item'], a['variation']) in input_addons[cp.id]:
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
raise CartError(error_messages['addon_duplicate_item']) raise CartError(error_messages['addon_duplicate_item'])
input_addons[cp.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].add((a['item'], a['variation'])) 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 # This add-on is new, add it to the cart
for quota in quotas: for quota in quotas:
quota_diff[quota] += 1 quota_diff[quota] += a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']])
if price_included[cp.pk].get(item.category_id):
price = TAXED_ZERO
else:
price = self._get_price(item, variation, None, None, cp.subevent)
op = self.AddOperation( 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, addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
price_before_voucher=None price_before_voucher=None
) )
@@ -685,7 +690,10 @@ class CartManager:
item = cp.item item = cp.item
for iao in item.addons.all(): for iao in item.addons.all():
selected = selected_addons[cp.id, iao.addon_category_id] 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 i18n
# TODO: Proper pluralization # TODO: Proper pluralization
raise CartError( raise CartError(
@@ -696,7 +704,7 @@ class CartManager:
'cat': str(iao.addon_category.name), 'cat': str(iao.addon_category.name),
} }
) )
elif len(selected) < iao.min_count: elif sum(selected.values()) < iao.min_count:
# TODO: Proper i18n # TODO: Proper i18n
# TODO: Proper pluralization # TODO: Proper pluralization
raise CartError( raise CartError(
@@ -707,28 +715,39 @@ class CartManager:
'cat': str(iao.addon_category.name), '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( validate_cart_addons.send(
sender=self.event, sender=self.event,
addons={ addons={
(self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None) (self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None): c
for s in selected for s, c in selected.items() if c > 0
}, },
base_position=cp, base_position=cp,
iao=iao iao=iao
) )
# Detect removed add-ons and create RemoveOperations # 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(): for k, v in al.items():
if k not in input_addons[cp.id]: input_num = input_addons[cp.id].get(k, 0)
if v.expires > self.now_dt: current_num = len(current_addons[cp].get(k, []))
quotas = list(v.quotas) 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: for quota in quotas:
quota_diff[quota] -= 1 quota_diff[quota] -= 1
op = self.RemoveOperation(position=v) op = self.RemoveOperation(position=a)
operations.append(op) operations.append(op)
self._quota_diff.update(quota_diff) self._quota_diff.update(quota_diff)
self._operations += operations self._operations += operations
+1 -1
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 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 ``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 as the ``ItemAddOn`` object as the argument ``iao`` and the base cart position as
``base_position``. ``base_position``.
The response of receivers will be ignored, but you can raise a CartError with an The response of receivers will be ignored, but you can raise a CartError with an
+2 -1
View File
@@ -661,7 +661,8 @@ class ItemAddOnForm(I18nModelForm):
'addon_category', 'addon_category',
'min_count', 'min_count',
'max_count', 'max_count',
'price_included' 'price_included',
'multi_allowed',
] ]
help_texts = { help_texts = {
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all ' 'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
@@ -43,6 +43,7 @@
{% bootstrap_field form.addon_category layout="control" %} {% bootstrap_field form.addon_category layout="control" %}
{% bootstrap_field form.min_count layout="control" %} {% bootstrap_field form.min_count layout="control" %}
{% bootstrap_field form.max_count layout="control" %} {% bootstrap_field form.max_count layout="control" %}
{% bootstrap_field form.multi_allowed layout="control" %}
{% bootstrap_field form.price_included layout="control" %} {% bootstrap_field form.price_included layout="control" %}
</div> </div>
</div> </div>
@@ -75,6 +76,7 @@
{% bootstrap_field formset.empty_form.addon_category layout="control" %} {% bootstrap_field formset.empty_form.addon_category layout="control" %}
{% bootstrap_field formset.empty_form.min_count 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.max_count layout="control" %}
{% bootstrap_field formset.empty_form.multi_allowed layout="control" %}
{% bootstrap_field formset.empty_form.price_included layout="control" %} {% bootstrap_field formset.empty_form.price_included layout="control" %}
</div> </div>
</div> </div>
+144 -48
View File
@@ -1,4 +1,5 @@
import inspect import inspect
from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from django.conf import settings 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 import Order
from pretix.base.models.orders import InvoiceAddress, OrderPayment from pretix.base.models.orders import InvoiceAddress, OrderPayment
from pretix.base.models.tax import TaxedPrice
from pretix.base.services.cart import ( 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.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.templatetags.rich_text import rich_text_snippet
from pretix.base.views.tasks import AsyncAction from pretix.base.views.tasks import AsyncAction
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.forms.checkout import ( from pretix.presale.forms.checkout import (
AddOnsForm, ContactForm, InvoiceAddressForm, InvoiceNameForm, ContactForm, InvoiceAddressForm, InvoiceNameForm,
) )
from pretix.presale.signals import ( from pretix.presale.signals import (
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps, checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
@@ -37,6 +40,7 @@ from pretix.presale.views import (
from pretix.presale.views.cart import ( from pretix.presale.views.cart import (
cart_session, create_empty_cart_id, get_or_create_cart_id, 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 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( for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
).order_by('pk'): ).order_by('pk'):
current_addon_products = {
a.item_id: a.variation_id for a in cartpos.addons.all() if not a.is_bundled
}
formsetentry = { formsetentry = {
'cartpos': cartpos, 'cartpos': cartpos,
'item': cartpos.item, 'item': cartpos.item,
'variation': cartpos.variation, 'variation': cartpos.variation,
'categories': [] '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) 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 return formset
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -274,37 +318,89 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
def get_error_url(self): def get_error_url(self):
return self.get_step_url(self.request) return self.get_step_url(self.request)
def get(self, request): def get(self, request, **kwargs):
self.request = request self.request = request
if 'async_id' in request.GET and settings.HAS_CELERY: if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request) return self.get_result(request)
return TemplateFlowStep.get(self, 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): def post(self, request, *args, **kwargs):
self.request = request self.request = request
is_valid = True
data = [] data = []
for f in self.forms: for f in self.forms:
for c in f['categories']: for c in f['categories']:
is_valid = is_valid and c['form'].is_valid() try:
if c['form'].is_valid(): selected = self._clean_category(f, c)
for k, v in c['form'].cleaned_data.items(): except ValidationError as e:
itemid = int(k[5:]) messages.error(request, e.message % e.params if e.params else e.message)
if v is True: return self.get(request, *args, **kwargs)
data.append({
'addon_to': f['cartpos'].pk,
'item': itemid,
'variation': None
})
elif v:
data.append({
'addon_to': f['cartpos'].pk,
'item': itemid,
'variation': int(v)
})
if not is_valid: for (i, v), (c, price) in selected.items():
return self.get(request, *args, **kwargs) 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), return self.do(self.request.event.id, data, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(), invoice_address=self.invoice_address.pk, locale=get_language(),
-238
View File
@@ -2,24 +2,13 @@ from itertools import chain
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Count, Prefetch
from django.utils.encoding import force_str 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 django.utils.translation import gettext_lazy as _
from pretix.base.forms.questions import ( from pretix.base.forms.questions import (
BaseInvoiceAddressForm, BaseQuestionsForm, 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.base.validators import EmailBanlistValidator
from pretix.helpers.templatetags.thumb import thumb
from pretix.presale.signals import contact_form_fields 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): if value == k or text_value == force_str(k):
return True return True
return False 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))
@@ -2,6 +2,9 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load rich_text %} {% load rich_text %}
{% load l10n %}
{% load money %}
{% load thumb %}
{% block inner %} {% block inner %}
<p> <p>
{% trans "For some of the products in your cart, you can choose additional options before you continue." %} {% 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 <form class="form-horizontal" method="post" data-asynctask
data-asynctask-headline="{% trans "We're now trying to book these add-ons for you!" %}"> data-asynctask-headline="{% trans "We're now trying to book these add-ons for you!" %}">
{% csrf_token %} {% csrf_token %}
<div class="panel-group" id="questions_group"> <div class="panel-group addons" id="questions_group">
{% for form in forms %} {% for form in forms %}
<details class="panel panel-default" open> <details class="panel panel-default" open>
<summary class="panel-heading"> <summary class="panel-heading">
@@ -45,7 +48,11 @@
{% plural %} {% plural %}
You need to choose {{ min_count }} options from this category. You need to choose {{ min_count }} options from this category.
{% endblocktrans %} {% 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 %} {% else %}
{% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %} {% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %}
You can choose between {{ min_count }} and {{ max_count }} options from You can choose between {{ min_count }} and {{ max_count }} options from
@@ -53,7 +60,265 @@
{% endblocktrans %} {% endblocktrans %}
{% endif %} {% endif %}
</p> </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> </fieldset>
{% empty %} {% empty %}
<em> <em>
+5 -4
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 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 'category', 'tax_rule', # for re-grouping
'hidden_if_available', 'hidden_if_available',
).prefetch_related( ).prefetch_related(
@@ -124,7 +125,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
else: else:
items = items.filter(requires_seat=0) items = items.filter(requires_seat=0)
display_add_to_cart = False 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 {} quota_cache = external_quota_cache or {}
if subevent: 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) 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) event.cache.set('item_quota_cache', quota_cache, 5)
items = [item for item in items items = [item for item in items
if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove] if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove]
@@ -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); el.find("input[name*=question], select[name*=question]").change(questions_toggle_dependent);
questions_toggle_dependent(); questions_toggle_dependent();
}; };
@@ -87,3 +87,12 @@
display: none; display: none;
} }
} }
.addons {
fieldset {
margin-top: 20px;
&:first-child {
margin-top: 0;
}
}
}
@@ -1,5 +1,11 @@
.product-row { .product-row {
border-top: 1px solid $table-border-color; 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 { &:last-child {
} }
+12 -1
View File
@@ -352,7 +352,8 @@ def test_item_detail_addons(token_client, organizer, event, team, item, category
"min_count": 0, "min_count": 0,
"max_count": 1, "max_count": 1,
"position": 0, "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, resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug,
item.pk)) item.pk))
@@ -575,6 +576,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 0, "position": 0,
"multi_allowed": False,
"price_included": True "price_included": True
} }
] ]
@@ -620,6 +622,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 0, "position": 0,
"multi_allowed": False,
"price_included": True "price_included": True
} }
] ]
@@ -663,6 +666,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"min_count": 110, "min_count": 110,
"max_count": 10, "max_count": 10,
"position": 0, "position": 0,
"multi_allowed": False,
"price_included": True "price_included": True
} }
] ]
@@ -706,6 +710,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"min_count": -1, "min_count": -1,
"max_count": 10, "max_count": 10,
"position": 0, "position": 0,
"multi_allowed": False,
"price_included": True "price_included": True
} }
] ]
@@ -920,6 +925,7 @@ def test_item_update(token_client, organizer, event, item, category, item2, cate
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 0, "position": 0,
"multi_allowed": False,
"price_included": True "price_included": True
} }
] ]
@@ -1011,6 +1017,7 @@ def test_item_update_with_addon(token_client, organizer, event, item, category):
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 0, "position": 0,
"multi_allowed": False,
"price_included": True "price_included": True
} }
] ]
@@ -1372,6 +1379,7 @@ TEST_ADDONS_RES = {
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 1, "position": 1,
"multi_allowed": False,
"price_included": False "price_included": False
} }
@@ -1410,6 +1418,7 @@ def test_addons_create(token_client, organizer, event, item, category, category2
"min_count": 0, "min_count": 0,
"max_count": 10, "max_count": 10,
"position": 1, "position": 1,
"multi_allowed": False,
"price_included": False "price_included": False
}, },
format='json' format='json'
@@ -1427,6 +1436,7 @@ def test_addons_create(token_client, organizer, event, item, category, category2
"min_count": 10, "min_count": 10,
"max_count": 20, "max_count": 20,
"position": 2, "position": 2,
"multi_allowed": False,
"price_included": False "price_included": False
}, },
format='json' format='json'
@@ -1441,6 +1451,7 @@ def test_addons_create(token_client, organizer, event, item, category, category2
"min_count": 10, "min_count": 10,
"max_count": 20, "max_count": 20,
"position": 2, "position": 2,
"multi_allowed": False,
"price_included": False "price_included": False
}, },
format='json' format='json'
+152
View File
@@ -2222,6 +2222,158 @@ class CartAddonTest(CartTestMixin, TestCase):
]) ])
self.cm.commit() 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') @classscope(attr='orga')
def test_no_duplicate_items_for_same_cp(self): def test_no_duplicate_items_for_same_cp(self):
cp1 = CartPosition.objects.create( cp1 = CartPosition.objects.create(
+44 -5
View File
@@ -2226,8 +2226,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
) )
response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), { 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', 'cp_{}_item_{}'.format(cp1.pk, self.workshop1.pk): '1',
'{}_{}-item_{}'.format(cp2.pk, self.workshopcat.pk, self.workshop2.pk): self.workshop2a.pk, 'cp_{}_variation_{}_{}'.format(cp2.pk, self.workshop2.pk, self.workshop2a.pk): '1',
}, follow=True) }, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200) target_status_code=200)
@@ -2236,6 +2236,45 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
assert cp2.addons.first().item == self.workshop2 assert cp2.addons.first().item == self.workshop2
assert cp2.addons.first().variation == self.workshop2a 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): def test_set_addons_required(self):
with scopes_disabled(): with scopes_disabled():
ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1) 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) 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), self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug),
target_status_code=200) 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): def test_set_addons_subevent_net_prices(self):
with scopes_disabled(): 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) 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), self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug),
target_status_code=200) target_status_code=200)
assert 'Workshop 1 (+ €35.29 plus 19.00% VAT)' in response.rendered_content assert '35.29' in response.rendered_content
assert 'A (+ €10.08 plus 19.00% VAT)' in response.rendered_content assert '10.08' in response.rendered_content
def test_confirm_subevent_presale_not_yet(self): def test_confirm_subevent_presale_not_yet(self):
with scopes_disabled(): with scopes_disabled():