diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index d547de5c72..453e8b87e6 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -36,6 +36,11 @@ from django_scopes import ScopedManager from pretix.base.decimal import round_decimal from pretix.base.models.base import LoggedModel +ITEM_ID = 0 +SUBEVENT_ID = 1 +LINE_PRICE_GROSS = 2 +IS_ADDON_TO = 3 +VOUCHER_DISCOUNT = 4 class Discount(LoggedModel): SUBEVENT_MODE_MIXED = 'mixed' @@ -245,22 +250,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): + 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, None, -1)] + + def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts): if len(condition_idx_group) < self.condition_min_count: return @@ -271,20 +280,41 @@ class Discount(LoggedModel): 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 + condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx)) # sort by line_price + benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx)) # sort by line_price # 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)) + possible_applications_cond = len(condition_idx_group) // self.condition_min_count # how many discount applications are allowed according to condition products in cart + possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches) # how many discount applications are possible according to benefitting products in cart + 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 and n_groups * self.benefit_only_apply_to_cheapest_n_matches > len( + benefit_idx_group): + # "angebrochener" 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)] + + if collect_potential_discounts is not None and possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len( + benefit_idx_group): + # "ungenutzter" 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:]): + collect_potential_discounts[idx] = [ + (self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count) + ] + 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, None, -1)] + 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,9 +322,9 @@ 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, Tuple[int, Optional[int], Decimal, bool, Decimal]], collect_potential_discounts=None) -> Dict[int, Decimal]: """ Tries to apply this discount to a cart @@ -342,9 +372,9 @@ 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) else: - self._apply_min_value(positions, condition_candidates, benefit_candidates, result) + self._apply_min_value(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts) elif self.subevent_mode == self.SUBEVENT_MODE_SAME: def key(idx): @@ -357,11 +387,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) else: - self._apply_min_value(positions, g, benefit_g, result) + self._apply_min_value(positions, g, benefit_g, result, collect_potential_discounts) elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT: if self.condition_min_value or not self.benefit_same_products: @@ -377,9 +407,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 +424,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 +433,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 +445,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 +462,7 @@ 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, + collect_potential_discounts ) return result diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 15459d8b3b..97e80d4ac3 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -38,7 +38,7 @@ import os import sys import uuid import warnings -from collections import Counter, OrderedDict +from collections import Counter, OrderedDict, defaultdict from datetime import date, datetime, time, timedelta from decimal import Decimal, DecimalException from typing import Optional, Tuple @@ -142,17 +142,34 @@ class ItemCategory(LoggedModel): verbose_name_plural = _("Product categories") ordering = ('position', 'id') - def cross_sell_visible(self, cart_positions): + def cross_sell_visible(self, cart, event, sales_channel): if self.cross_selling_mode is None: - return False + return [] if self.cross_selling_condition == 'always': - return True + return self.items.all() if self.cross_selling_condition == 'products': match = set(match.pk for match in self.cross_selling_match_products.only('pk')) # TODO prefetch this - return any(pos.item.pk in match for pos in cart_positions) + return self.items.all() if any(pos.item.pk in match for pos in cart) else [] if self.cross_selling_condition == 'discounts': - # TODO not sure how to do this yet - return False + # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarrrrgggghhhhhh + + from ..services.pricing import apply_discounts + + potential_discounts_dict = defaultdict(list) + discount_results = apply_discounts( + event, + 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 cart + ], + collect_potential_discounts=potential_discounts_dict + ) + print("potential_discounts_dict", potential_discounts_dict) + potential_discounts = {info for lst in potential_discounts_dict.values() for info in lst} + potential_discount_items = {item.pk for (discount_rule, max_count, i) in potential_discounts for item in discount_rule.benefit_limit_products.all()} + return self.items.filter(pk__in=potential_discount_items) def __str__(self): name = self.internal_name or self.name diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index acfdc44b0d..ccd39a1b76 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -156,7 +156,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu def apply_discounts(event: Event, sales_channel: str, - positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]: + positions: List[Tuple[int, Optional[int], Decimal, bool, bool]], + collect_potential_discounts=None) -> List[Decimal]: """ Applies any dynamic discounts to a cart @@ -176,11 +177,13 @@ def apply_discounts(event: Event, sales_channel: str, active=True, ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') for discount in discount_qs: + if collect_potential_discounts is not None: + print("checking discount",discount) result = discount.apply({ idx: (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) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 341ebcefe3..ab508c082c 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -490,14 +490,21 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): if not hasattr(request, '_checkoutflow_addons_applicable'): cart = get_cart(request) self.request = request - request._checkoutflow_addons_applicable = (cart.filter(item__addons__isnull=False).exists() - or any(self.cross_selling_applicable_rules)) + request._checkoutflow_addons_applicable =('/addons/' in request.path_info or cart.filter(item__addons__isnull=False).exists() + or any(self.cross_selling_applicable_categories)) return request._checkoutflow_addons_applicable @cached_property - def cross_selling_applicable_rules(self): - cart = self.get_cart(self.request) - return [c for c in self.request.event.categories.filter(cross_selling_mode__isnull=False) if c.cross_sell_visible(cart['positions'])] + def cross_selling_applicable_categories(self): + cart = get_cart(self.request) + return [ + (c, products) for (c, products) in + ( + (c, c.cross_sell_visible(cart, self.request.event, self.request.sales_channel.identifier)) + for c in self.request.event.categories.filter(cross_selling_mode__isnull=False) + ) + if len(products) > 0 + ] def is_completed(self, request, warn=False): if getattr(self, '_completed', None) is not None: @@ -622,23 +629,23 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): if self.event.has_subevents: return [ - (DummyCategory(rule, subevent), self._items_from_rule(rule, subevent), f'subevent_{subevent.pk}_') - for rule in self.cross_selling_applicable_rules + (DummyCategory(category, subevent), self._items_for_cross_selling(subevent, items), f'subevent_{subevent.pk}_') + for (category, items) in self.cross_selling_applicable_categories for subevent in set(pos.subevent for pos in ctx['cart']['positions']) ] else: return [ - (rule, self._items_from_rule(rule, None)) - for rule in self.cross_selling_applicable_rules + (category, self._items_for_cross_selling(None, items)) + for (category, items) in self.cross_selling_applicable_categories ] - def _items_from_rule(self, rule, subevent): + def _items_for_cross_selling(self, subevent, items): items, _btn = get_grouped_items( self.request.event, subevent=subevent, voucher=None, channel=self.request.sales_channel.identifier, - base_qs=rule.items.all(), + base_qs=items, allow_addons=True, allow_cross_sell=True, memberships=( @@ -752,7 +759,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): 'price': price, }) - add_to_cart_items = _items_from_post_data(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(), diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 62682f824c..7b7cdc29f1 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -227,7 +227,7 @@ def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_r except ValueError: raise CartError(_('Please enter numbers only.')) -def _items_from_post_data(request): +def _items_from_post_data(request, warn_if_empty=True): """ Parses the POST data and returns a list of dictionaries """ @@ -254,7 +254,7 @@ def _items_from_post_data(request): if item: items.append(item) - if len(items) == 0: + if len(items) == 0 and warn_if_empty: messages.warning(request, _('You did not select any products.')) return [] return items