forked from CGM_Public/pretix_original
Allow selecting the same add-on multiple times (#1717)
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user