mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user