forked from CGM_Public/pretix_original
Cross selling (#4185)
Product categories can now be marked as "cross-selling categories", causing them to appear in the add-on checkout step as additional recommendations, depending on their cross-selling visibility (always, only if certain products are already in the cart, or only if they qualify for a discount according to discount rules). --------- Co-authored-by: Raphael Michel <michel@rami.io> Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
@@ -441,7 +441,22 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ItemCategory
|
||||
fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon')
|
||||
fields = (
|
||||
'id', 'name', 'internal_name', 'description', 'position',
|
||||
'is_addon', 'cross_selling_mode',
|
||||
'cross_selling_condition', 'cross_selling_match_products'
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
if full_data.get('is_addon') and full_data.get('cross_selling_mode'):
|
||||
raise ValidationError('is_addon and cross_selling_mode are mutually exclusive')
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class QuestionOptionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 4.2.11 on 2024-05-27 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0270_historicpassword"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="itemcategory",
|
||||
name="cross_selling_condition",
|
||||
field=models.CharField(null=True, max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="itemcategory",
|
||||
name="cross_selling_mode",
|
||||
field=models.CharField(null=True, max_length=5),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="itemcategory",
|
||||
name="cross_selling_match_products",
|
||||
field=models.ManyToManyField(
|
||||
related_name="matched_by_cross_selling_categories", to="pretixbase.item"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -20,11 +20,11 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from collections import defaultdict
|
||||
from collections import defaultdict, namedtuple
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from math import ceil
|
||||
from typing import Dict, Optional, Tuple
|
||||
from math import ceil, inf
|
||||
from typing import Dict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
@@ -36,6 +36,8 @@ from django_scopes import ScopedManager
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
|
||||
PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount'])
|
||||
|
||||
|
||||
class Discount(LoggedModel):
|
||||
SUBEVENT_MODE_MIXED = 'mixed'
|
||||
@@ -245,22 +247,26 @@ class Discount(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
|
||||
if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value:
|
||||
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
||||
if self.condition_min_value and sum(positions[idx].line_price_gross for idx in condition_idx_group) < self.condition_min_value:
|
||||
return
|
||||
|
||||
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
|
||||
raise ValueError('Validation invariant violated.')
|
||||
|
||||
for idx in benefit_idx_group:
|
||||
previous_price = positions[idx][2]
|
||||
previous_price = positions[idx].line_price_gross
|
||||
new_price = round_decimal(
|
||||
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
|
||||
self.event.currency,
|
||||
)
|
||||
result[idx] = new_price
|
||||
|
||||
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
|
||||
if collect_potential_discounts is not None:
|
||||
for idx in condition_idx_group:
|
||||
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
|
||||
|
||||
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
||||
if len(condition_idx_group) < self.condition_min_count:
|
||||
return
|
||||
|
||||
@@ -268,23 +274,53 @@ class Discount(LoggedModel):
|
||||
raise ValueError('Validation invariant violated.')
|
||||
|
||||
if self.benefit_only_apply_to_cheapest_n_matches:
|
||||
if not self.condition_min_count:
|
||||
raise ValueError('Validation invariant violated.')
|
||||
|
||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
|
||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
|
||||
# sort by line_price
|
||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
||||
|
||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||
# want to match multiples of 3
|
||||
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
|
||||
|
||||
# how many discount applications are allowed according to condition products in cart
|
||||
possible_applications_cond = len(condition_idx_group) // self.condition_min_count
|
||||
|
||||
# how many discount applications are possible according to benefitting products in cart
|
||||
possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches)
|
||||
|
||||
n_groups = min(possible_applications_cond, possible_applications_benefit)
|
||||
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
|
||||
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
|
||||
|
||||
if collect_potential_discounts is not None:
|
||||
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
|
||||
# partially used discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
|
||||
# but only 1 t-shirt) -> 1 shirt definitiv potential discount
|
||||
for idx in consume_idx:
|
||||
collect_potential_discounts[idx] = [
|
||||
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id)
|
||||
]
|
||||
|
||||
if possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
|
||||
# unused discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
|
||||
# but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount)
|
||||
for i, idx in enumerate(condition_idx_group[
|
||||
n_groups * self.condition_min_count:
|
||||
possible_applications_cond * self.condition_min_count
|
||||
]):
|
||||
collect_potential_discounts[idx] += [
|
||||
(self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count, subevent_id)
|
||||
]
|
||||
|
||||
else:
|
||||
consume_idx = condition_idx_group
|
||||
benefit_idx = benefit_idx_group
|
||||
|
||||
if collect_potential_discounts is not None:
|
||||
for idx in consume_idx:
|
||||
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
|
||||
|
||||
for idx in benefit_idx:
|
||||
previous_price = positions[idx][2]
|
||||
previous_price = positions[idx].line_price_gross
|
||||
new_price = round_decimal(
|
||||
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
|
||||
self.event.currency,
|
||||
@@ -292,15 +328,16 @@ class Discount(LoggedModel):
|
||||
result[idx] = new_price
|
||||
|
||||
for idx in consume_idx:
|
||||
result.setdefault(idx, positions[idx][2])
|
||||
result.setdefault(idx, positions[idx].line_price_gross)
|
||||
|
||||
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
|
||||
def apply(self, positions: Dict[int, PositionInfo],
|
||||
collect_potential_discounts=None) -> Dict[int, Decimal]:
|
||||
"""
|
||||
Tries to apply this discount to a cart
|
||||
|
||||
:param positions: Dictionary mapping IDs to tuples of the form
|
||||
``(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)``.
|
||||
:param positions: Dictionary mapping IDs to PositionInfo tuples.
|
||||
Bundled positions may not be included.
|
||||
:param collect_potential_discounts: For detailed description, see pretix.base.services.pricing.apply_discounts
|
||||
|
||||
:return: A dictionary mapping keys from the input dictionary to new prices. All positions
|
||||
contained in this dictionary are considered "consumed" and should not be considered
|
||||
@@ -342,13 +379,13 @@ class Discount(LoggedModel):
|
||||
|
||||
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
|
||||
if self.condition_min_count:
|
||||
self._apply_min_count(positions, condition_candidates, benefit_candidates, result)
|
||||
self._apply_min_count(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None)
|
||||
else:
|
||||
self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
|
||||
self._apply_min_value(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None)
|
||||
|
||||
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
|
||||
def key(idx):
|
||||
return positions[idx][1] or 0 # subevent_id
|
||||
return positions[idx].subevent_id or 0
|
||||
|
||||
# Build groups of candidates with the same subevent, then apply our regular algorithm
|
||||
# to each group
|
||||
@@ -357,11 +394,11 @@ class Discount(LoggedModel):
|
||||
candidate_groups = [(k, list(g)) for k, g in _groups]
|
||||
|
||||
for subevent_id, g in candidate_groups:
|
||||
benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
|
||||
benefit_g = [idx for idx in benefit_candidates if positions[idx].subevent_id == subevent_id]
|
||||
if self.condition_min_count:
|
||||
self._apply_min_count(positions, g, benefit_g, result)
|
||||
self._apply_min_count(positions, g, benefit_g, result, collect_potential_discounts, subevent_id)
|
||||
else:
|
||||
self._apply_min_value(positions, g, benefit_g, result)
|
||||
self._apply_min_value(positions, g, benefit_g, result, collect_potential_discounts, subevent_id)
|
||||
|
||||
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
|
||||
if self.condition_min_value or not self.benefit_same_products:
|
||||
@@ -377,9 +414,9 @@ class Discount(LoggedModel):
|
||||
# Build a list of subevent IDs in descending order of frequency
|
||||
subevent_to_idx = defaultdict(list)
|
||||
for idx, p in positions.items():
|
||||
subevent_to_idx[p[1]].append(idx)
|
||||
subevent_to_idx[p.subevent_id].append(idx)
|
||||
for v in subevent_to_idx.values():
|
||||
v.sort(key=lambda idx: positions[idx][2])
|
||||
v.sort(key=lambda idx: positions[idx].line_price_gross)
|
||||
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
|
||||
|
||||
# Build groups of exactly condition_min_count distinct subevents
|
||||
@@ -394,7 +431,7 @@ class Discount(LoggedModel):
|
||||
l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
|
||||
if cardinality and len(l) != cardinality:
|
||||
continue
|
||||
if se not in {positions[idx][1] for idx in current_group}:
|
||||
if se not in {positions[idx].subevent_id for idx in current_group}:
|
||||
candidates += l
|
||||
cardinality = len(l)
|
||||
|
||||
@@ -403,7 +440,7 @@ class Discount(LoggedModel):
|
||||
|
||||
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
|
||||
# and 2 from the end" scheme to optimize price distribution among groups
|
||||
candidates = sorted(candidates, key=lambda idx: positions[idx][2])
|
||||
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
|
||||
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
||||
candidate = candidates[0]
|
||||
else:
|
||||
@@ -415,14 +452,14 @@ class Discount(LoggedModel):
|
||||
if len(current_group) >= max(self.condition_min_count, 1):
|
||||
candidate_groups.append(current_group)
|
||||
for c in current_group:
|
||||
subevent_to_idx[positions[c][1]].remove(c)
|
||||
subevent_to_idx[positions[c].subevent_id].remove(c)
|
||||
current_group = []
|
||||
|
||||
# Distribute "leftovers"
|
||||
for se in subevent_order:
|
||||
if subevent_to_idx[se]:
|
||||
for group in candidate_groups:
|
||||
if se not in {positions[idx][1] for idx in group}:
|
||||
if se not in {positions[idx].subevent_id for idx in group}:
|
||||
group.append(subevent_to_idx[se].pop())
|
||||
if not subevent_to_idx[se]:
|
||||
break
|
||||
@@ -432,6 +469,8 @@ class Discount(LoggedModel):
|
||||
positions,
|
||||
[idx for idx in g if idx in condition_candidates],
|
||||
[idx for idx in g if idx in benefit_candidates],
|
||||
result
|
||||
result,
|
||||
None,
|
||||
None
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -870,10 +870,12 @@ class Event(EventMixin, LoggedModel):
|
||||
for i in Item.objects.filter(event=other).prefetch_related(
|
||||
'variations', 'limit_sales_channels', 'require_membership_types',
|
||||
'variations__limit_sales_channels', 'variations__require_membership_types',
|
||||
'matched_by_cross_selling_categories',
|
||||
):
|
||||
vars = list(i.variations.all())
|
||||
require_membership_types = list(i.require_membership_types.all())
|
||||
limit_sales_channels = list(i.limit_sales_channels.all())
|
||||
matched_by_cross_selling_categories = list(i.matched_by_cross_selling_categories.all())
|
||||
item_map[i.pk] = i
|
||||
i.pk = None
|
||||
i.event = self
|
||||
@@ -911,6 +913,9 @@ class Event(EventMixin, LoggedModel):
|
||||
if not v.all_sales_channels:
|
||||
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
|
||||
|
||||
if matched_by_cross_selling_categories:
|
||||
i.matched_by_cross_selling_categories.set([category_map[c.pk] for c in matched_by_cross_selling_categories])
|
||||
|
||||
for i in self.items.filter(hidden_if_item_available__isnull=False):
|
||||
i.hidden_if_item_available = item_map[i.hidden_if_item_available_id]
|
||||
i.save()
|
||||
|
||||
@@ -63,14 +63,13 @@ from django_countries.fields import Country
|
||||
from django_scopes import ScopedManager
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import Event, SubEvent
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
|
||||
from ...helpers.images import ImageSizeValidator
|
||||
from ..media import MEDIA_TYPES
|
||||
from .event import Event, SubEvent
|
||||
from pretix.helpers.images import ImageSizeValidator
|
||||
|
||||
|
||||
class ItemCategory(LoggedModel):
|
||||
@@ -111,6 +110,33 @@ class ItemCategory(LoggedModel):
|
||||
'only be bought in combination with a product that has this category configured as a possible '
|
||||
'source for add-ons.')
|
||||
)
|
||||
CROSS_SELLING_MODES = (
|
||||
(None, _('Normal category')),
|
||||
('both', _('Normal + cross-selling category')),
|
||||
('only', _('Cross-selling category')),
|
||||
)
|
||||
cross_selling_mode = models.CharField(
|
||||
choices=CROSS_SELLING_MODES,
|
||||
null=True,
|
||||
max_length=5
|
||||
)
|
||||
CROSS_SELLING_CONDITION = (
|
||||
('always', _('Always show in cross-selling step')),
|
||||
('discounts', _('Only show products that qualify for a discount according to discount rules')),
|
||||
('products', _('Only show if the cart contains one of the following products')),
|
||||
)
|
||||
cross_selling_condition = models.CharField(
|
||||
verbose_name=_("Cross-selling condition"),
|
||||
choices=CROSS_SELLING_CONDITION,
|
||||
null=True,
|
||||
max_length=10,
|
||||
)
|
||||
cross_selling_match_products = models.ManyToManyField(
|
||||
'pretixbase.Item',
|
||||
blank=True,
|
||||
verbose_name=_("Cross-selling condition products"),
|
||||
related_name="matched_by_cross_selling_categories",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product category")
|
||||
@@ -119,19 +145,31 @@ class ItemCategory(LoggedModel):
|
||||
|
||||
def __str__(self):
|
||||
name = self.internal_name or self.name
|
||||
if self.is_addon:
|
||||
return _('{category} (Add-On products)').format(category=str(name))
|
||||
category_type = self.get_category_type_display()
|
||||
if category_type:
|
||||
return _('{category} ({category_type})').format(category=str(name), category_type=category_type)
|
||||
return str(name)
|
||||
|
||||
def get_category_type_display(self):
|
||||
if self.is_addon:
|
||||
return _('Add-On products')
|
||||
return _('Add-on category')
|
||||
elif self.cross_selling_mode:
|
||||
return self.get_cross_selling_mode_display()
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def category_type(self):
|
||||
return 'addon' if self.is_addon else 'normal'
|
||||
return 'addon' if self.is_addon else self.cross_selling_mode or 'normal'
|
||||
|
||||
@category_type.setter
|
||||
def category_type(self, new_value):
|
||||
if new_value == 'addon':
|
||||
self.is_addon = True
|
||||
self.cross_selling_mode = None
|
||||
else:
|
||||
self.is_addon = False
|
||||
self.cross_selling_mode = None if new_value == 'normal' else new_value
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -270,7 +308,7 @@ class SubEventItemVariation(models.Model):
|
||||
return True
|
||||
|
||||
|
||||
def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
||||
def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
|
||||
# Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel
|
||||
# makes the query SIGNIFICANTLY faster
|
||||
from .organizer import SalesChannel
|
||||
@@ -291,6 +329,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
||||
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
if not allow_cross_sell:
|
||||
q &= Q(Q(category__isnull=True) | ~Q(category__cross_selling_mode='only'))
|
||||
|
||||
if voucher:
|
||||
if voucher.item_id:
|
||||
@@ -304,8 +344,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
||||
|
||||
|
||||
class ItemQuerySet(models.QuerySet):
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
return filter_available(self, channel, voucher, allow_addons)
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
|
||||
return filter_available(self, channel, voucher, allow_addons, allow_cross_sell)
|
||||
|
||||
|
||||
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
|
||||
@@ -313,8 +353,8 @@ class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__)
|
||||
super().__init__()
|
||||
self._queryset_class = ItemQuerySet
|
||||
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
return filter_available(self.get_queryset(), channel, voucher, allow_addons)
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
|
||||
return filter_available(self.get_queryset(), channel, voucher, allow_addons, allow_cross_sell)
|
||||
|
||||
|
||||
class Item(LoggedModel):
|
||||
|
||||
@@ -1542,10 +1542,9 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param voucher: A voucher code
|
||||
:param session: Session ID of a guest
|
||||
:param cart_id: The cart ID of the cart to modify
|
||||
"""
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
try:
|
||||
@@ -1566,10 +1565,10 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
Removes an item specified by its position ID from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param position: A cart position ID
|
||||
:param session: Session ID of a guest
|
||||
:param cart_id: The cart ID of the cart to modify
|
||||
"""
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
try:
|
||||
@@ -1590,9 +1589,9 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
Removes all items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param session: Session ID of a guest
|
||||
:param cart_id: The cart ID of the cart to modify
|
||||
"""
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
try:
|
||||
@@ -1611,13 +1610,15 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
|
||||
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
|
||||
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
|
||||
ensure the requested addon state.
|
||||
:param event: The event ID in question
|
||||
:param addons: A list of dicts with the keys addon_to, item, variation
|
||||
:param session: Session ID of a guest
|
||||
:param add_to_cart_items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID
|
||||
:param cart_id: The cart ID of the cart to modify
|
||||
"""
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
ia = False
|
||||
@@ -1635,6 +1636,7 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
|
||||
cm.set_addons(addons)
|
||||
cm.add_new_items(add_to_cart_items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
232
src/pretix/base/services/cross_selling.py
Normal file
232
src/pretix/base/services/cross_selling.py
Normal file
@@ -0,0 +1,232 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from math import inf
|
||||
from typing import List
|
||||
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
|
||||
from pretix.presale.views.event import get_grouped_items
|
||||
|
||||
|
||||
class DummyCategory:
|
||||
"""
|
||||
Used to create fake category objects for displaying the same cross-selling category multiple times,
|
||||
once for each subevent
|
||||
"""
|
||||
|
||||
def __init__(self, category: ItemCategory, subevent):
|
||||
self.id = category.id
|
||||
self.name = str(category.name)
|
||||
self.subevent_name = str(subevent)
|
||||
self.description = category.description
|
||||
|
||||
|
||||
class CrossSellingService:
|
||||
def __init__(self, event, sales_channel: SalesChannel, cartpositions: List[CartPosition], customer):
|
||||
self.event = event
|
||||
self.sales_channel = sales_channel
|
||||
self.cartpositions = cartpositions
|
||||
self.customer = customer
|
||||
|
||||
def get_data(self):
|
||||
if self.event.has_subevents:
|
||||
subevents = set(pos.subevent for pos in self.cartpositions)
|
||||
result = (
|
||||
(DummyCategory(category, subevent),
|
||||
self._prepare_items(subevent, items_qs, discount_info),
|
||||
f'subevent_{subevent.pk}_')
|
||||
for subevent in subevents
|
||||
for (category, items_qs, discount_info) in self._applicable_categories(subevent.pk)
|
||||
)
|
||||
else:
|
||||
result = (
|
||||
(category,
|
||||
self._prepare_items(None, items_qs, discount_info),
|
||||
'')
|
||||
for (category, items_qs, discount_info) in self._applicable_categories(0)
|
||||
)
|
||||
result = [(category, items, form_prefix) for (category, items, form_prefix) in result if len(items) > 0]
|
||||
for category, items, form_prefix in result:
|
||||
category.category_has_discount = any(item.original_price for item in items)
|
||||
return result
|
||||
|
||||
def _applicable_categories(self, subevent_id):
|
||||
return [
|
||||
(c, products_qs, discount_info) for (c, products_qs, discount_info) in
|
||||
(
|
||||
(c, *self._get_visible_items_for_category(subevent_id, c))
|
||||
for c in self.event.categories.filter(cross_selling_mode__isnull=False).prefetch_related('items')
|
||||
)
|
||||
if products_qs is not None
|
||||
]
|
||||
|
||||
def _get_visible_items_for_category(self, filter_subevent_id, category: ItemCategory):
|
||||
"""
|
||||
If this category should be visible in the cross-selling step for a given cart and sales_channel, this method
|
||||
returns a queryset of the items that should be displayed, as well as a dict giving additional information on them.
|
||||
|
||||
:returns: (QuerySet<Item>, dict<(subevent_id, item_pk): (max_count, discount_rule)>)
|
||||
max_count is `inf` if the item should not be limited
|
||||
discount_rule is None if the item will not be discounted
|
||||
"""
|
||||
if category.cross_selling_mode is None:
|
||||
return None, {}
|
||||
if category.cross_selling_condition == 'always':
|
||||
return category.items.all(), {}
|
||||
if category.cross_selling_condition == 'products':
|
||||
match = set(match.pk for match in category.cross_selling_match_products.only('pk')) # TODO prefetch this
|
||||
return (category.items.all(), {}) if any(pos.item.pk in match for pos in self.cartpositions) else (None, {})
|
||||
if category.cross_selling_condition == 'discounts':
|
||||
my_item_pks = [item.id for item in category.items.all()]
|
||||
potential_discount_items = {
|
||||
item.pk: (max_count, discount_rule)
|
||||
for subevent_id, item, max_count, discount_rule in self._potential_discounts_by_subevent_and_item_for_current_cart
|
||||
if max_count > 0 and item.pk in my_item_pks and item.is_available() and (subevent_id == filter_subevent_id or subevent_id is None)
|
||||
}
|
||||
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
|
||||
|
||||
@cached_property
|
||||
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
|
||||
potential_discounts_by_cartpos = defaultdict(list)
|
||||
|
||||
from ..services.pricing import apply_discounts
|
||||
self._discounted_prices = apply_discounts(
|
||||
self.event,
|
||||
self.sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled,
|
||||
cp.listed_price - cp.price_after_voucher)
|
||||
for cp in self.cartpositions
|
||||
],
|
||||
collect_potential_discounts=potential_discounts_by_cartpos
|
||||
)
|
||||
|
||||
# flatten potential_discounts_by_cartpos (a dict of lists of potential discounts) into a set of potential discounts
|
||||
# (which is technically stored as a dict, but we use it as an OrderedSet here)
|
||||
potential_discount_set = dict.fromkeys(
|
||||
info for lst in potential_discounts_by_cartpos.values() for info in lst)
|
||||
|
||||
# sum up the max_counts and pass them on (also pass on the discount_rules so we can calculate actual discounted prices later):
|
||||
# group by benefit product
|
||||
# - max_count for product: sum up max_counts
|
||||
# - discount_rule for product: take first discount_rule
|
||||
|
||||
def discount_info(subevent_id, item, infos_for_item):
|
||||
infos_for_item = list(infos_for_item)
|
||||
return (
|
||||
subevent_id,
|
||||
item,
|
||||
sum(max_count for (subevent_id, item, discount_rule, max_count, i) in infos_for_item),
|
||||
next(discount_rule for (subevent_id, item, discount_rule, max_count, i) in infos_for_item),
|
||||
)
|
||||
|
||||
return [
|
||||
discount_info(subevent_id, item, infos_for_item) for (subevent_id, item), infos_for_item in
|
||||
groupby(
|
||||
sorted(
|
||||
(
|
||||
(subevent_id, item, discount_rule, max_count, i)
|
||||
for (discount_rule, max_count, i, subevent_id) in potential_discount_set.keys()
|
||||
for item in discount_rule.benefit_limit_products.all()
|
||||
),
|
||||
key=lambda tup: (tup[0], tup[1].pk)
|
||||
),
|
||||
lambda tup: (tup[0], tup[1]))
|
||||
]
|
||||
|
||||
def _prepare_items(self, subevent, items_qs, discount_info):
|
||||
items, _btn = get_grouped_items(
|
||||
self.event,
|
||||
subevent=subevent,
|
||||
voucher=None,
|
||||
channel=self.sales_channel,
|
||||
base_qs=items_qs,
|
||||
allow_addons=False,
|
||||
allow_cross_sell=True,
|
||||
memberships=(
|
||||
self.customer.usable_memberships(
|
||||
for_event=subevent or self.event,
|
||||
testmode=self.event.testmode
|
||||
)
|
||||
if self.customer else None
|
||||
),
|
||||
)
|
||||
new_items = list()
|
||||
for item in items:
|
||||
max_count = inf
|
||||
if item.pk in discount_info:
|
||||
(max_count, discount_rule) = discount_info[item.pk]
|
||||
|
||||
# only benefit_only_apply_to_cheapest_n_matches discounted items have a max_count, all others get 'inf'
|
||||
if not max_count:
|
||||
max_count = inf
|
||||
|
||||
# calculate discounted price
|
||||
if discount_rule and discount_rule.benefit_discount_matching_percent > 0:
|
||||
if not item.has_variations:
|
||||
item.original_price = item.original_price or item.display_price
|
||||
previous_price = item.display_price
|
||||
new_price = (
|
||||
previous_price * (
|
||||
(Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00'))
|
||||
)
|
||||
item.display_price = new_price
|
||||
else:
|
||||
# discounts always match "whole" items, not specific variations -> we apply the discount to all
|
||||
# available variations of the item
|
||||
for var in item.available_variations:
|
||||
var.original_price = var.original_price or var.display_price
|
||||
previous_price = var.display_price
|
||||
new_price = (
|
||||
previous_price * (
|
||||
(Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00'))
|
||||
)
|
||||
var.display_price = new_price
|
||||
|
||||
if not item.has_variations:
|
||||
# reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway)
|
||||
item.order_max = min(
|
||||
item.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk),
|
||||
max_count
|
||||
)
|
||||
if item.order_max > 0:
|
||||
new_items.append(item)
|
||||
else:
|
||||
new_vars = list()
|
||||
for var in item.available_variations:
|
||||
# reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway)
|
||||
var.order_max = min(
|
||||
var.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk and pos.variation_id == var.pk),
|
||||
max_count
|
||||
)
|
||||
if var.order_max > 0:
|
||||
new_vars.append(var)
|
||||
if len(new_vars):
|
||||
item.available_variations = new_vars
|
||||
new_items.append(item)
|
||||
|
||||
return new_items
|
||||
@@ -20,8 +20,9 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
@@ -31,6 +32,7 @@ from pretix.base.models import (
|
||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
|
||||
SalesChannel, Voucher,
|
||||
)
|
||||
from pretix.base.models.discount import Discount, PositionInfo
|
||||
from pretix.base.models.event import Event, SubEvent
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
@@ -155,14 +157,22 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
||||
return price
|
||||
|
||||
|
||||
def apply_discounts(event: Event, sales_channel: str,
|
||||
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
|
||||
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
positions: List[Tuple[int, Optional[int], Decimal, bool, bool, Decimal]],
|
||||
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
||||
"""
|
||||
Applies any dynamic discounts to a cart
|
||||
|
||||
:param event: Event the cart belongs to
|
||||
:param sales_channel: Sales channel the cart was created with
|
||||
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
|
||||
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
|
||||
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
|
||||
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
|
||||
of tuples describing the discounts that could be applied in the form `(discount, max_count, grouping_id)`.
|
||||
`max_count` is either the maximum number of benefitting items that the discount would apply to, or `inf` if that number
|
||||
is not limited. The `grouping_id` can be used to distinguish several occurrences of the same discount.
|
||||
|
||||
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
|
||||
"""
|
||||
if isinstance(sales_channel, SalesChannel):
|
||||
@@ -177,10 +187,10 @@ def apply_discounts(event: Event, sales_channel: str,
|
||||
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||
for discount in discount_qs:
|
||||
result = discount.apply({
|
||||
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
|
||||
idx: PositionInfo(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
|
||||
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
|
||||
if not is_bundled and idx not in new_prices
|
||||
})
|
||||
}, collect_potential_discounts)
|
||||
for k in result.keys():
|
||||
result[k] = (result[k], discount)
|
||||
new_prices.update(result)
|
||||
|
||||
@@ -40,7 +40,8 @@ from urllib.parse import urlencode
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Max
|
||||
from django.db.models import Max, Q
|
||||
from django.forms import ChoiceField, RadioSelect
|
||||
from django.forms.formsets import DELETION_FIELD_NAME
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
@@ -79,11 +80,67 @@ class CategoryForm(I18nModelForm):
|
||||
'name',
|
||||
'internal_name',
|
||||
'description',
|
||||
'is_addon'
|
||||
'cross_selling_condition',
|
||||
'cross_selling_match_products',
|
||||
]
|
||||
widgets = {
|
||||
'description': I18nMarkdownTextarea,
|
||||
'cross_selling_condition': RadioSelect,
|
||||
}
|
||||
field_classes = {
|
||||
'cross_selling_match_products': SafeModelMultipleChoiceField,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
tpl = '{} <span class="text-muted">{}</span>'
|
||||
self.fields['category_type'] = ChoiceField(widget=RadioSelect, choices=(
|
||||
('normal', mark_safe(tpl.format(
|
||||
_('Normal category'),
|
||||
_('Products in this category are regular products displayed on the front page.')
|
||||
)),),
|
||||
('addon', mark_safe(tpl.format(
|
||||
_('Add-on product category'),
|
||||
_('Products in this category are add-on products and can only be bought as add-ons.')
|
||||
)),),
|
||||
('only', mark_safe(tpl.format(
|
||||
_('Cross-selling category'),
|
||||
_('Products in this category are regular products, but are only shown in the cross-selling step, '
|
||||
'according to the configuration below.')
|
||||
)),),
|
||||
('both', mark_safe(tpl.format(
|
||||
_('Normal + cross-selling category'),
|
||||
_('Products in this category are regular products displayed on the front page, but are additionally '
|
||||
'shown in the cross-selling step, according to the configuration below.')
|
||||
)),),
|
||||
))
|
||||
self.fields['category_type'].initial = self.instance.category_type
|
||||
|
||||
self.fields['cross_selling_condition'].widget.attrs['data-display-dependency'] = '#id_category_type_2,#id_category_type_3'
|
||||
self.fields['cross_selling_condition'].widget.attrs['data-disable-dependent'] = 'true'
|
||||
self.fields['cross_selling_condition'].widget.choices = self.fields['cross_selling_condition'].widget.choices[1:]
|
||||
self.fields['cross_selling_condition'].required = False
|
||||
|
||||
self.fields['cross_selling_match_products'].widget = forms.CheckboxSelectMultiple(
|
||||
attrs={
|
||||
'class': 'scrolling-multiple-choice',
|
||||
'data-display-dependency': '#id_cross_selling_condition_2'
|
||||
}
|
||||
)
|
||||
self.fields['cross_selling_match_products'].queryset = self.event.items.filter(
|
||||
# don't show products which are only visible in addon/cross-sell step themselves
|
||||
Q(category__isnull=True) | Q(
|
||||
Q(category__is_addon=False) & Q(Q(category__cross_selling_mode='both') | Q(category__cross_selling_mode__isnull=True))
|
||||
)
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d.get('category_type') == 'only' or d.get('category_type') == 'both':
|
||||
if not d.get('cross_selling_condition'):
|
||||
raise ValidationError({'cross_selling_condition': [_('This field is required')]})
|
||||
self.instance.category_type = d.get('category_type')
|
||||
return d
|
||||
|
||||
|
||||
class QuestionForm(I18nModelForm):
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Product categories" %}</th>
|
||||
<th>{% trans "Category type" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -40,6 +41,9 @@
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ c.get_category_type_display }}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<button title="{% trans "Move up" %}" formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
|
||||
<button title="{% trans "Move down" %}" formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
{% bootstrap_field form.internal_name layout="control" %}
|
||||
</div>
|
||||
{% bootstrap_field form.description layout="control" %}
|
||||
{% bootstrap_field form.is_addon layout="control" %}
|
||||
{% bootstrap_field form.category_type layout="control" horizontal_field_class="big-radio-wrapper col-lg-9" %}
|
||||
{% bootstrap_field form.cross_selling_condition layout="control" horizontal_field_class="col-lg-9" %}
|
||||
{% bootstrap_field form.cross_selling_match_products layout="control" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
{% if category %}
|
||||
|
||||
@@ -65,6 +65,7 @@ from pretix.base.services.cart import (
|
||||
CartError, CartManager, add_payment_to_cart, error_messages, get_fees,
|
||||
set_cart_addons,
|
||||
)
|
||||
from pretix.base.services.cross_selling import CrossSellingService
|
||||
from pretix.base.services.memberships import validate_memberships_in_order
|
||||
from pretix.base.services.orders import perform_order
|
||||
from pretix.base.services.tasks import EventTask
|
||||
@@ -93,7 +94,8 @@ from pretix.presale.views import (
|
||||
CartMixin, get_cart, get_cart_is_free, get_cart_total,
|
||||
)
|
||||
from pretix.presale.views.cart import (
|
||||
cart_session, create_empty_cart_id, get_or_create_cart_id,
|
||||
_items_from_post_data, 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
|
||||
@@ -486,9 +488,31 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
label = pgettext_lazy('checkoutflow', 'Add-on products')
|
||||
icon = 'puzzle-piece'
|
||||
|
||||
def _check_is_applicable(self, request):
|
||||
self.request = request
|
||||
|
||||
# check whether addons are applicable
|
||||
if get_cart(request).filter(item__addons__isnull=False).exists():
|
||||
return True
|
||||
|
||||
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
|
||||
cur_step_identifier = request.resolver_match.kwargs.get('step')
|
||||
is_past_this_step = any(step.identifier == cur_step_identifier for step in request._checkout_flow[request._checkout_flow.index(self) + 1:])
|
||||
if is_past_this_step:
|
||||
applicable = self.cart_session.get('_checkoutflow_addons_applicable', None)
|
||||
if applicable is not None:
|
||||
return applicable
|
||||
|
||||
# check whether cross-selling is applicable
|
||||
applicable = self.cross_selling_is_applicable
|
||||
self.cart_session['_checkoutflow_addons_applicable'] = applicable
|
||||
return applicable
|
||||
|
||||
def is_applicable(self, request):
|
||||
if not hasattr(request, '_checkoutflow_addons_applicable'):
|
||||
request._checkoutflow_addons_applicable = get_cart(request).filter(item__addons__isnull=False).exists()
|
||||
cur_step_identifier = request.resolver_match.kwargs.get('step')
|
||||
request._checkoutflow_addons_applicable = self._check_is_applicable(request) or cur_step_identifier == self.identifier
|
||||
|
||||
return request._checkoutflow_addons_applicable
|
||||
|
||||
def is_completed(self, request, warn=False):
|
||||
@@ -605,10 +629,21 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
formset.append(formsetentry)
|
||||
return formset
|
||||
|
||||
@cached_property
|
||||
def cross_selling_is_applicable(self):
|
||||
return any(len(items) > 0 for (category, items, form_prefix) in self.cross_selling_data)
|
||||
|
||||
@cached_property
|
||||
def cross_selling_data(self):
|
||||
return CrossSellingService(
|
||||
self.request.event, self.request.sales_channel, self.positions, self.request.customer
|
||||
).get_data()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['forms'] = self.forms
|
||||
ctx['cart'] = self.get_cart()
|
||||
ctx['cross_selling_data'] = self.cross_selling_data
|
||||
return ctx
|
||||
|
||||
def get_success_message(self, value):
|
||||
@@ -687,7 +722,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
data = []
|
||||
addons = []
|
||||
for f in self.forms:
|
||||
for c in f['categories']:
|
||||
try:
|
||||
@@ -697,7 +732,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
for (i, v), (c, price) in selected.items():
|
||||
data.append({
|
||||
addons.append({
|
||||
'addon_to': f['pos'].pk,
|
||||
'item': i.pk,
|
||||
'variation': v.pk if v else None,
|
||||
@@ -705,7 +740,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
'price': price,
|
||||
})
|
||||
|
||||
return self.do(self.request.event.id, data, get_or_create_cart_id(self.request),
|
||||
add_to_cart_items = _items_from_post_data(self.request, warn_if_empty=False)
|
||||
|
||||
return self.do(self.request.event.id, addons, add_to_cart_items, get_or_create_cart_id(self.request),
|
||||
invoice_address=self.invoice_address.pk, locale=get_language(),
|
||||
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
{% load money %}
|
||||
{% load thumb %}
|
||||
{% block inner %}
|
||||
<p>
|
||||
{% trans "For some of the products in your cart, you can choose additional options before you continue." %}
|
||||
</p>
|
||||
|
||||
{% if forms %}
|
||||
<p>
|
||||
{% trans "For some of the products in your cart, you can choose additional options before you continue." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<form class="form-horizontal" method="post" data-asynctask
|
||||
data-asynctask-headline="{% trans "We're now trying to book these add-ons for you!" %}">
|
||||
{% csrf_token %}
|
||||
@@ -17,10 +20,12 @@
|
||||
<details class="panel panel-default" open>
|
||||
<summary class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
<span class="sr-only">{% trans "Add-ons:" %}</span>
|
||||
<strong>{{ form.item.name }}{% if form.variation %}
|
||||
– {{ form.variation }}
|
||||
{% endif %}</strong>
|
||||
<span>
|
||||
{% trans "Additional options for" %}
|
||||
<strong>{{ form.item.name }}{% if form.variation %}
|
||||
– {{ form.variation }}
|
||||
{% endif %}</strong>
|
||||
</span>
|
||||
</h3>
|
||||
</summary>
|
||||
<div id="cp{{ form.pos.pk }}">
|
||||
@@ -46,6 +51,20 @@
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if cross_selling_data %}
|
||||
<details class="panel panel-default cross-selling" open>
|
||||
<summary class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "More recommendations" %}
|
||||
</h3>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
{% include "pretixpresale/event/fragment_product_list.html" with items_by_category=cross_selling_data ev=event %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div class="row checkout-button-row">
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<a class="btn btn-block btn-default btn-lg"
|
||||
|
||||
@@ -5,20 +5,31 @@
|
||||
{% load thumb %}
|
||||
{% load eventsignal %}
|
||||
{% load rich_text %}
|
||||
{% for tup in items_by_category %}
|
||||
{% if tup.0 %}
|
||||
<section aria-labelledby="category-{{ tup.0.id }}"{% if tup.0.description %} aria-describedby="category-info-{{ tup.0.id }}"{% endif %}>
|
||||
<h3 id="category-{{ tup.0.id }}">{{ tup.0.name }}</h3>
|
||||
{% if tup.0.description %}
|
||||
<div id="category-info-{{ tup.0.id }}">{{ tup.0.description|localize|rich_text }}</div>
|
||||
{% for tup in items_by_category %}{% with category=tup.0 items=tup.1 form_prefix=tup.2 %}
|
||||
{% if category %}
|
||||
<section aria-labelledby="{{ form_prefix }}category-{{ category.id }}"{% if category.description %} aria-describedby="{{ form_prefix }}category-info-{{ category.id }}"{% endif %}>
|
||||
<h3 id="{{ form_prefix }}category-{{ category.id }}">{{ category.name }}
|
||||
{% if category.subevent_name %}
|
||||
<small class="text-muted"><i class="fa fa-calendar"></i> {{ category.subevent_name }}</small>
|
||||
{% endif %}
|
||||
{% if category.category_has_discount %}
|
||||
<small class="text-success">
|
||||
<i class="fa fa-star" aria-hidden="true"></i>
|
||||
<span class="sr-only">Congratulations!</span>
|
||||
{% trans "Your order qualifies for a discount" %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if category.description %}
|
||||
<div id="{{ form_prefix }}category-info-{{ category.id }}">{{ category.description|localize|rich_text }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<section aria-labelledby="category-none">
|
||||
<h3 id="category-none" class="sr-only">{% trans "Uncategorized items" %}</h3>
|
||||
<section aria-labelledby="{{ form_prefix }}category-none">
|
||||
<h3 id="{{ form_prefix }}category-none" class="sr-only">{% trans "Uncategorized items" %}</h3>
|
||||
{% endif %}
|
||||
{% for item in tup.1 %}
|
||||
{% for item in items %}
|
||||
{% if item.has_variations %}
|
||||
<article aria-labelledby="item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="item-{{ item.pk }}-description"{% endif %} class="item-with-variations{% if event.settings.show_variations_expanded %} details-open{% endif %}" id="item-{{ item.pk }}">
|
||||
<article aria-labelledby="{{ form_prefix }}item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-description"{% endif %} class="item-with-variations{% if event.settings.show_variations_expanded %} details-open{% endif %}" id="{{ form_prefix }}item-{{ item.pk }}">
|
||||
<div class="row product-row headline">
|
||||
<div class="col-md-8 col-sm-6 col-xs-12">
|
||||
{% if item.picture %}
|
||||
@@ -32,9 +43,9 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="product-description {% if item.picture %}with-picture{% endif %}">
|
||||
<h4 id="item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
<h4 id="{{ form_prefix }}item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
{% if item.description %}
|
||||
<div id="item-{{ item.pk }}-description" class="product-description">
|
||||
<div id="{{ form_prefix }}item-{{ item.pk }}-description" class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -101,14 +112,14 @@
|
||||
</div>
|
||||
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}">
|
||||
{% for var in item.available_variations %}
|
||||
<article aria-labelledby="item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row product-row variation" id="item-{{ item.pk }}-{{ var.pk }}"
|
||||
<article aria-labelledby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row product-row variation" id="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}"
|
||||
{% if not item.free_price %}
|
||||
data-price="{% if event.settings.display_net_prices %}{{ var.display_price.net|unlocalize }}{% else %}{{ var.display_price.gross|unlocalize }}{% endif %}"
|
||||
{% endif %}>
|
||||
<div class="col-md-8 col-sm-6 col-xs-12">
|
||||
<h5 id="item-{{ item.pk }}-{{ var.pk }}-legend">{{ var }}</h5>
|
||||
<h5 id="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-legend">{{ var }}</h5>
|
||||
{% if var.description %}
|
||||
<div id="item-{{ item.pk }}-{{ var.pk }}-description" class="variation-description">
|
||||
<div id="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description" class="variation-description">
|
||||
{{ var.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -139,11 +150,11 @@
|
||||
<div class="input-group input-group-price">
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price"
|
||||
id="price-variation-{{ item.pk }}-{{ var.pk }}"
|
||||
id="{{ form_prefix }}price-variation-{{ item.pk }}-{{ var.pk }}"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}
|
||||
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="price_{{ item.id }}_{{ var.id }}"
|
||||
name="{{ form_prefix }}price_{{ item.id }}_{{ var.id }}"
|
||||
{% if var.suggested_price.gross != var.display_price.gross %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
title="{% blocktrans trimmed with item=var.value price=var.display_price.net|money:event.currency %}Modify price for {{ item }}, at least {{ price }}{% endblocktrans %}"
|
||||
@@ -200,16 +211,16 @@
|
||||
data-checked-onchange="price-variation-{{ item.pk }}-{{ var.pk }}"
|
||||
{% endif %}
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}
|
||||
id="variation_{{ item.id }}_{{ var.id }}"
|
||||
name="variation_{{ item.id }}_{{ var.id }}"
|
||||
id="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
|
||||
name="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
|
||||
aria-label="{% blocktrans with item=item.name var=var %}Add {{ item }}, {{ var }} to cart{% endblocktrans %}"
|
||||
{% if var.description %} aria-describedby="item-{{ item.pk }}-{{ var.pk }}-description"{% endif %}>
|
||||
{% if var.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description"{% endif %}>
|
||||
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
|
||||
{% trans "Select" context "checkbox" %}
|
||||
</label>
|
||||
{% else %}
|
||||
<div class="input-item-count-group">
|
||||
<button type="button" data-step="-1" data-controls="variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
|
||||
<button type="button" data-step="-1" data-controls="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}>-</button>
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}
|
||||
@@ -217,10 +228,10 @@
|
||||
data-checked-onchange="price-variation-{{ item.pk }}-{{ var.pk }}"
|
||||
{% endif %}
|
||||
max="{{ var.order_max }}"
|
||||
id="variation_{{ item.id }}_{{ var.id }}"
|
||||
name="variation_{{ item.id }}_{{ var.id }}"
|
||||
id="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
|
||||
name="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
|
||||
aria-label="{% blocktrans with item=item.name var=var.name %}Quantity of {{ item }}, {{ var }} to order{% endblocktrans %}">
|
||||
<button type="button" data-step="1" data-controls="variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
|
||||
<button type="button" data-step="1" data-controls="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}>+</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -234,7 +245,7 @@
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<article aria-labelledby="item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="item-{{ item.pk }}-description"{% endif %} class="row product-row simple" id="item-{{ item.pk }}"
|
||||
<article aria-labelledby="{{ form_prefix }}item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-description"{% endif %} class="row product-row simple" id="{{ form_prefix }}item-{{ item.pk }}"
|
||||
{% if not item.free_price %}
|
||||
data-price="{% if event.settings.display_net_prices %}{{ item.display_price.net|unlocalize }}{% else %}{{ item.display_price.gross|unlocalize }}{% endif %}"
|
||||
{% endif %}>
|
||||
@@ -250,9 +261,9 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="product-description {% if item.picture %}with-picture{% endif %}">
|
||||
<h4 id="item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
<h4 id="{{ form_prefix }}item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
{% if item.description %}
|
||||
<div id="item-{{ item.pk }}-description" class="product-description">
|
||||
<div id="{{ form_prefix }}item-{{ item.pk }}-description" class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -293,10 +304,10 @@
|
||||
<label class="sr-only" for="price-item-{{ item.pk }}">{% blocktrans trimmed with item=item.name currency=event.currency %}Set price in {{ currency }} for {{ item }}{% endblocktrans %}</label>
|
||||
<span class="input-group-addon" aria-hidden="true">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price" placeholder="0"
|
||||
id="price-item-{{ item.pk }}"
|
||||
id="{{ form_prefix }}price-item-{{ item.pk }}"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}
|
||||
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="price_{{ item.id }}"
|
||||
name="{{ form_prefix }}price_{{ item.id }}"
|
||||
{% if item.suggested_price.gross != item.display_price.gross %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
title="{% blocktrans trimmed with item=item.name price=item.display_price.net|money:event.currency %}Modify price for {{ item }}, at least {{ price }}{% endblocktrans %}"
|
||||
@@ -352,15 +363,15 @@
|
||||
data-checked-onchange="price-item-{{ item.pk }}"
|
||||
{% endif %}
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}
|
||||
name="item_{{ item.id }}" id="item_{{ item.id }}"
|
||||
name="{{ form_prefix }}item_{{ item.id }}" id="{{ form_prefix }}item_{{ item.id }}"
|
||||
aria-label="{% blocktrans with item=item.name %}Add {{ item }} to cart{% endblocktrans %}"
|
||||
{% if item.description %} aria-describedby="item-{{ item.id }}-description"{% endif %}>
|
||||
{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.id }}-description"{% endif %}>
|
||||
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
|
||||
{% trans "Select" context "checkbox" %}
|
||||
</label>
|
||||
{% else %}
|
||||
<div class="input-item-count-group">
|
||||
<button type="button" data-step="-1" data-controls="item_{{ item.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
|
||||
<button type="button" data-step="-1" data-controls="{{ form_prefix }}item_{{ item.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}>-</button>
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}
|
||||
@@ -369,11 +380,11 @@
|
||||
data-checked-onchange="price-item-{{ item.pk }}"
|
||||
{% endif %}
|
||||
max="{{ item.order_max }}"
|
||||
name="item_{{ item.id }}"
|
||||
id="item_{{ item.id }}"
|
||||
name="{{ form_prefix }}item_{{ item.id }}"
|
||||
id="{{ form_prefix }}item_{{ item.id }}"
|
||||
aria-label="{% blocktrans with item=item.name %}Quantity of {{ item }} to order{% endblocktrans %}"
|
||||
{% if item.description %} aria-describedby="item-{{ item.id }}-description"{% endif %}>
|
||||
<button type="button" data-step="1" data-controls="item_{{ item.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
|
||||
{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.id }}-description"{% endif %}>
|
||||
<button type="button" data-step="1" data-controls="{{ form_prefix }}item_{{ item.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
|
||||
{% if not ev.presale_is_running %}disabled{% endif %}>+</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -386,4 +397,4 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endwith %}{% endfor %}
|
||||
|
||||
@@ -318,14 +318,14 @@ def cart_exists(request):
|
||||
|
||||
def get_cart(request):
|
||||
from pretix.presale.views.cart import get_or_create_cart_id
|
||||
qqs = request.event.questions.all()
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False)
|
||||
|
||||
if not hasattr(request, '_cart_cache'):
|
||||
cart_id = get_or_create_cart_id(request, create=False)
|
||||
if not cart_id:
|
||||
request._cart_cache = CartPosition.objects.none()
|
||||
else:
|
||||
qqs = request.event.questions.all()
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False)
|
||||
request._cart_cache = CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=request.event
|
||||
).annotate(
|
||||
|
||||
@@ -151,104 +151,114 @@ class CartActionMixin:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return InvoiceAddress()
|
||||
|
||||
def _item_from_post_value(self, key, value, voucher=None, voucher_ignore_if_redeemed=False):
|
||||
if value.strip() == '' or '_' not in key:
|
||||
return
|
||||
|
||||
if not key.startswith('item_') and not key.startswith('variation_') and not key.startswith('seat_'):
|
||||
return
|
||||
|
||||
parts = key.split("_")
|
||||
price = self.request.POST.get('price_' + "_".join(parts[1:]), "")
|
||||
subevent = None
|
||||
if 'subevent' in self.request.POST:
|
||||
try:
|
||||
subevent = int(self.request.POST.get('subevent'))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if key.startswith('seat_'):
|
||||
try:
|
||||
return {
|
||||
'item': int(parts[1]),
|
||||
'variation': int(parts[2]) if len(parts) > 2 else None,
|
||||
'count': 1,
|
||||
'seat': value,
|
||||
'price': price,
|
||||
'voucher': voucher,
|
||||
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
|
||||
'subevent': subevent
|
||||
}
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_redeemed=False):
|
||||
if value.strip() == '' or '_' not in key:
|
||||
return
|
||||
|
||||
subevent = None
|
||||
if key.startswith('subevent_'):
|
||||
try:
|
||||
amount = int(value)
|
||||
parts = key.split('_', 2)
|
||||
subevent = int(parts[1])
|
||||
key = parts[2]
|
||||
except ValueError:
|
||||
pass
|
||||
elif 'subevent' in request.POST:
|
||||
try:
|
||||
subevent = int(request.POST.get('subevent'))
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
if amount < 0:
|
||||
raise CartError(_('Please enter positive numbers only.'))
|
||||
elif amount == 0:
|
||||
return
|
||||
|
||||
if key.startswith('item_'):
|
||||
try:
|
||||
return {
|
||||
'item': int(parts[1]),
|
||||
'variation': None,
|
||||
'count': amount,
|
||||
'price': price,
|
||||
'voucher': voucher,
|
||||
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
|
||||
'subevent': subevent
|
||||
}
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
elif key.startswith('variation_'):
|
||||
try:
|
||||
return {
|
||||
'item': int(parts[1]),
|
||||
'variation': int(parts[2]),
|
||||
'count': amount,
|
||||
'price': price,
|
||||
'voucher': voucher,
|
||||
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
|
||||
'subevent': subevent
|
||||
}
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
|
||||
def _items_from_post_data(self):
|
||||
"""
|
||||
Parses the POST data and returns a list of dictionaries
|
||||
"""
|
||||
|
||||
# Compatibility patch that makes the frontend code a lot easier
|
||||
req_items = list(self.request.POST.lists())
|
||||
if '_voucher_item' in self.request.POST and '_voucher_code' in self.request.POST:
|
||||
req_items.append((
|
||||
'%s' % self.request.POST['_voucher_item'], ('1',)
|
||||
))
|
||||
pass
|
||||
|
||||
items = []
|
||||
if 'raw' in self.request.POST:
|
||||
items += json.loads(self.request.POST.get("raw"))
|
||||
for key, values in req_items:
|
||||
for value in values:
|
||||
try:
|
||||
item = self._item_from_post_value(key, value, self.request.POST.get('_voucher_code'),
|
||||
voucher_ignore_if_redeemed=self.request.POST.get('_voucher_ignore_if_redeemed') == 'on')
|
||||
except CartError as e:
|
||||
messages.error(self.request, str(e))
|
||||
return
|
||||
if item:
|
||||
items.append(item)
|
||||
if not key.startswith('item_') and not key.startswith('variation_') and not key.startswith('seat_'):
|
||||
return
|
||||
|
||||
if len(items) == 0:
|
||||
messages.warning(self.request, _('You did not select any products.'))
|
||||
return []
|
||||
return items
|
||||
parts = key.split("_")
|
||||
price = request.POST.get('price_' + "_".join(parts[1:]), "")
|
||||
|
||||
if key.startswith('seat_'):
|
||||
try:
|
||||
return {
|
||||
'item': int(parts[1]),
|
||||
'variation': int(parts[2]) if len(parts) > 2 else None,
|
||||
'count': 1,
|
||||
'seat': value,
|
||||
'price': price,
|
||||
'voucher': voucher,
|
||||
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
|
||||
'subevent': subevent
|
||||
}
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
|
||||
try:
|
||||
amount = int(value)
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
if amount < 0:
|
||||
raise CartError(_('Please enter positive numbers only.'))
|
||||
elif amount == 0:
|
||||
return
|
||||
|
||||
if key.startswith('item_'):
|
||||
try:
|
||||
return {
|
||||
'item': int(parts[1]),
|
||||
'variation': None,
|
||||
'count': amount,
|
||||
'price': price,
|
||||
'voucher': voucher,
|
||||
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
|
||||
'subevent': subevent
|
||||
}
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
elif key.startswith('variation_'):
|
||||
try:
|
||||
return {
|
||||
'item': int(parts[1]),
|
||||
'variation': int(parts[2]),
|
||||
'count': amount,
|
||||
'price': price,
|
||||
'voucher': voucher,
|
||||
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
|
||||
'subevent': subevent
|
||||
}
|
||||
except ValueError:
|
||||
raise CartError(_('Please enter numbers only.'))
|
||||
|
||||
|
||||
def _items_from_post_data(request, warn_if_empty=True):
|
||||
"""
|
||||
Parses the POST data and returns a list of dictionaries
|
||||
"""
|
||||
|
||||
# Compatibility patch that makes the frontend code a lot easier
|
||||
req_items = list(request.POST.lists())
|
||||
if '_voucher_item' in request.POST and '_voucher_code' in request.POST:
|
||||
req_items.append((
|
||||
'%s' % request.POST['_voucher_item'], ('1',)
|
||||
))
|
||||
pass
|
||||
|
||||
items = []
|
||||
if 'raw' in request.POST:
|
||||
items += json.loads(request.POST.get("raw"))
|
||||
for key, values in req_items:
|
||||
for value in values:
|
||||
try:
|
||||
item = _item_from_post_value(request, key, value, request.POST.get('_voucher_code'),
|
||||
voucher_ignore_if_redeemed=request.POST.get('_voucher_ignore_if_redeemed') == 'on')
|
||||
except CartError as e:
|
||||
messages.error(request, str(e))
|
||||
return
|
||||
if item:
|
||||
items.append(item)
|
||||
|
||||
if len(items) == 0 and warn_if_empty:
|
||||
messages.warning(request, _('You did not select any products.'))
|
||||
return []
|
||||
return items
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
@@ -534,7 +544,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
cs = cart_session(request)
|
||||
widget_data = cs.get('widget_data', {})
|
||||
|
||||
items = self._items_from_post_data()
|
||||
items = _items_from_post_data(self.request)
|
||||
if items:
|
||||
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
|
||||
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,
|
||||
|
||||
@@ -111,7 +111,8 @@ def item_group_by_category(items):
|
||||
)
|
||||
|
||||
|
||||
def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None, allow_addons=False,
|
||||
def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None,
|
||||
allow_addons=False, allow_cross_sell=False,
|
||||
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
|
||||
ignore_hide_sold_out_for_item_ids=None):
|
||||
base_qs_set = base_qs is not None
|
||||
@@ -193,7 +194,9 @@ def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=No
|
||||
)
|
||||
)
|
||||
|
||||
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel.identifier, voucher=voucher, allow_addons=allow_addons).select_related(
|
||||
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(
|
||||
channel=channel.identifier, voucher=voucher, allow_addons=allow_addons, allow_cross_sell=allow_cross_sell
|
||||
).select_related(
|
||||
'category', 'tax_rule', # for re-grouping
|
||||
'hidden_if_available',
|
||||
).prefetch_related(
|
||||
|
||||
@@ -397,6 +397,9 @@ var form_handlers = function (el) {
|
||||
enabled = !enabled;
|
||||
}
|
||||
var $toggling = dependent;
|
||||
if (dependent.attr("data-disable-dependent")) {
|
||||
$toggling.attr('disabled', !enabled);
|
||||
}
|
||||
if (dependent.get(0).tagName.toLowerCase() !== "div") {
|
||||
$toggling = dependent.closest('.form-group');
|
||||
}
|
||||
@@ -411,8 +414,9 @@ var form_handlers = function (el) {
|
||||
}
|
||||
};
|
||||
update();
|
||||
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("change", update);
|
||||
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("dp.change", update);
|
||||
dependency.each(function() {
|
||||
$(this).closest('.form-group').find('[name=' + $(this).attr("name") + ']').on("change dp.change", update);
|
||||
})
|
||||
});
|
||||
|
||||
el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () {
|
||||
|
||||
@@ -567,7 +567,7 @@ table td > .checkbox input[type="checkbox"] {
|
||||
}
|
||||
}
|
||||
}
|
||||
.form-horizontal .big-radio {
|
||||
.form-horizontal .big-radio, .form-horizontal .big-radio-wrapper .radio {
|
||||
border: 1px solid #ccc;
|
||||
border-bottom: 0;
|
||||
padding: 0;
|
||||
@@ -588,6 +588,17 @@ table td > .checkbox input[type="checkbox"] {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
}
|
||||
.form-horizontal .control-label:has(+.big-radio-wrapper),
|
||||
.form-horizontal .control-label:has(+div > .big-radio) {
|
||||
padding-top: 16px;
|
||||
}
|
||||
.form-horizontal .big-radio-wrapper .radio label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-horizontal .big-radio-wrapper .radio label > span {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
}
|
||||
.accordion-radio {
|
||||
display: block;
|
||||
margin: 0;
|
||||
|
||||
@@ -102,6 +102,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.cross-selling .panel-body h3 {
|
||||
font-size: 21px;
|
||||
line-height: inherit;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.cross-selling .panel-body > *:first-child > h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.cross-selling .panel-body h3 small {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
|
||||
.panel-confirm {
|
||||
|
||||
Reference in New Issue
Block a user