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