diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 309968303..18d0997d8 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -34,6 +34,7 @@ # License for the specific language governing permissions and limitations under the License. import calendar +import functools import os import sys import uuid @@ -41,6 +42,7 @@ import warnings from collections import Counter, OrderedDict, defaultdict from datetime import date, datetime, time, timedelta from decimal import Decimal, DecimalException +from itertools import groupby from typing import Optional, Tuple from zoneinfo import ZoneInfo @@ -142,14 +144,22 @@ class ItemCategory(LoggedModel): verbose_name_plural = _("Product categories") ordering = ('position', 'id') - def cross_sell_visible(self, cart, event, sales_channel): + def cross_sell_visible(self, cart, sales_channel): + """ + If this category should be visible in the cross-selling step for a given cart and sales_channel, this method + returns a dict describing the items that should be displayed. + + :returns: dict {item: (max_count, discount_rule)} + max_count is None if the item should not be limited + discount_rule is None if the item will not be discounted + """ if self.cross_selling_mode is None: return [] if self.cross_selling_condition == 'always': - return self.items.all() + return {item: (None, None) for item in 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 self.items.all() if any(pos.item.pk in match for pos in cart) else [] + return {item: (None, None) for item in self.items.all()} if any(pos.item.pk in match for pos in cart) else [] if self.cross_selling_condition == 'discounts': # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarrrrgggghhhhhh @@ -157,7 +167,7 @@ class ItemCategory(LoggedModel): potential_discounts_dict = defaultdict(list) discount_results = apply_discounts( - event, + self.event, sales_channel, [ (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, @@ -167,10 +177,38 @@ class ItemCategory(LoggedModel): 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} - # TODO sum up the max_counts and pass them on (also pass on the discount_rules so we can calculate actual discounted prices later) - 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) + potential_discount_infos = dict.fromkeys(info for lst in potential_discounts_dict.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 + + grouped_by_item = [ + (item, list(infos_for_item)) for item, infos_for_item in + groupby( + sorted( + ( + (item, discount_rule, max_count, i) + for (discount_rule, max_count, i) in potential_discount_infos.keys() + for item in discount_rule.benefit_limit_products.all() + ), + key=lambda tup: tup[0].pk + ), + lambda tup: tup[0]) + ] + + def sum_or_none(iter): + return functools.reduce(lambda x,y: None if x is None or y is None else x + y, iter, 0) + + my_item_pks = self.items.values_list('pk', flat=True) + print("grouped:",grouped_by_item) + potential_discount_items = {item: (sum_or_none(max_count for (item, discount_rule, max_count, i) in infos_for_item), next(discount_rule for (item, discount_rule, max_count, i) in infos_for_item)) + for item, infos_for_item in grouped_by_item + if item.pk in my_item_pks} + + #potential_discount_items = {item.pk for (discount_rule, max_count, i) in potential_discount_infos.keys() for item in discount_rule.benefit_limit_products.all()} + return 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 ccd39a1b7..c1b68d820 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -186,6 +186,8 @@ def apply_discounts(event: Event, sales_channel: str, }, collect_potential_discounts) for k in result.keys(): result[k] = (result[k], discount) + if collect_potential_discounts is not None: + print(" ->",collect_potential_discounts) new_prices.update(result) return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)] diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 30c046772..d144697ef 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -55,6 +55,7 @@ from django.utils.translation import ( from django.views.generic.base import TemplateResponseMixin from django_scopes import scopes_disabled +from pretix.base.decimal import round_decimal from pretix.base.models import Customer, Membership, Order from pretix.base.models.items import Question from pretix.base.models.orders import ( @@ -500,7 +501,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): return [ (c, products) for (c, products) in ( - (c, c.cross_sell_visible(cart, self.request.event, self.request.sales_channel.identifier)) + (c, c.cross_sell_visible(cart, self.request.sales_channel.identifier)) for c in self.request.event.categories.filter(cross_selling_mode__isnull=False) ) if len(products) > 0 @@ -639,13 +640,13 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): for (category, items) in self.cross_selling_applicable_categories ] - def _items_for_cross_selling(self, subevent, items): + def _items_for_cross_selling(self, subevent, cross_sell_item_info): items, _btn = get_grouped_items( self.request.event, subevent=subevent, voucher=None, channel=self.request.sales_channel.identifier, - base_qs=items, + base_qs=cross_sell_item_info.keys(), allow_addons=True, allow_cross_sell=True, memberships=( @@ -656,8 +657,24 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): if self.request.customer else None ), ) - # TODO calculate discounted price - # TODO set item.order_max for benefit_only_apply_to_cheapest_n_matches discounted items + + for item in items: + (max_count, discount_rule) = cross_sell_item_info[item] + + # set item.order_max for benefit_only_apply_to_cheapest_n_matches discounted items + if max_count: + item.order_max = min(item.order_max, max_count) + + # calculate discounted price + if discount_rule: + item.original_price = item.original_price or item.display_price + previous_price = item.display_price + new_price = round_decimal( + previous_price * (Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00'), + self.event.currency, + ) + item.display_price = new_price + return items def get_context_data(self, **kwargs):