diff --git a/src/pretix/base/migrations/0265_itemcategory_cross_selling.py b/src/pretix/base/migrations/0265_itemcategory_cross_selling.py index 2601818c2..208d91bc7 100644 --- a/src/pretix/base/migrations/0265_itemcategory_cross_selling.py +++ b/src/pretix/base/migrations/0265_itemcategory_cross_selling.py @@ -1,6 +1,7 @@ # Generated by Django 4.2.11 on 2024-05-27 13:19 from django.db import migrations, models + import pretix.base.models.orders diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index 2e987ad94..ea58e511e 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -42,6 +42,7 @@ LINE_PRICE_GROSS = 2 IS_ADDON_TO = 3 VOUCHER_DISCOUNT = 4 + class Discount(LoggedModel): SUBEVENT_MODE_MIXED = 'mixed' SUBEVENT_MODE_SAME = 'same' @@ -280,30 +281,39 @@ 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][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 + # 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 - 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 + + # 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 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: + if 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) - ] + if 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 @@ -324,7 +334,8 @@ class Discount(LoggedModel): for idx in consume_idx: result.setdefault(idx, positions[idx][LINE_PRICE_GROSS]) - def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]], collect_potential_discounts=None) -> 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 diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 64c7580b3..a6b6dad82 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -121,6 +121,7 @@ class ItemCategory(LoggedModel): cross_selling_mode = models.CharField( choices=CROSS_SELLING_MODES, null=True, + max_length=5 ) CROSS_SELLING_CONDITION = ( ('always', _('Always show in cross-selling step')), @@ -131,6 +132,7 @@ class ItemCategory(LoggedModel): verbose_name=_("Cross-selling condition"), choices=CROSS_SELLING_CONDITION, null=True, + max_length=10, ) cross_selling_match_products = models.ManyToManyField( 'pretixbase.Item', @@ -158,15 +160,13 @@ class ItemCategory(LoggedModel): if self.cross_selling_condition == 'always': 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 + 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 ([], {}) if self.cross_selling_condition == 'discounts': - # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarrrrgggghhhhhh + potential_discounts_dict = defaultdict(list) from ..services.pricing import apply_discounts - - potential_discounts_dict = defaultdict(list) - discount_results = apply_discounts( + apply_discounts( self.event, sales_channel, [ @@ -176,7 +176,6 @@ class ItemCategory(LoggedModel): ], collect_potential_discounts=potential_discounts_dict ) - print("potential_discounts_dict", potential_discounts_dict) 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): @@ -199,15 +198,18 @@ class ItemCategory(LoggedModel): ] 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) + 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.pk: (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: ( + 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 self.items.filter(pk__in=potential_discount_items), potential_discount_items def __str__(self): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index c1b68d820..10b289b27 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -177,8 +177,6 @@ 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) @@ -186,8 +184,6 @@ 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/control/forms/item.py b/src/pretix/control/forms/item.py index 93173b1ec..956d10a30 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -81,17 +81,12 @@ class CategoryForm(I18nModelForm): 'name', 'internal_name', 'description', - #'is_addon' 'cross_selling_condition', 'cross_selling_match_products', ] widgets = { 'description': I18nMarkdownTextarea, 'cross_selling_condition': RadioSelect, - #'is_addon': BooleanRadio( - # 'normal', _('Normal category'), - # 'addon', _('Products in this category are add-on products'), - #) } field_classes = { 'cross_selling_match_products': SafeModelMultipleChoiceField, @@ -99,18 +94,20 @@ class CategoryForm(I18nModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - #self.fields['is_addon'].label = _('Category type') self.fields['category_type'] = ChoiceField(widget=RadioSelect, choices=( - ('normal', mark_safe('{}   {}'.format(_('Normal category'), _('Products in this category are regular products displayed on the front page.'))),), - ('addon', mark_safe('{}   {}'.format(_('Add-on product category'), _('Products in this category are add-on products and can only be bought as add-ons.'))),), - ('only', mark_safe('{}   {}'.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('{}   {}'.format(_('Combined 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.'))),), + ('normal', mark_safe('{}   {}'.format( + _('Normal category'), _('Products in this category are regular products displayed on the front page.'))),), + ('addon', mark_safe('{}   {}'.format( + _('Add-on product category'), _('Products in this category are add-on products and can only be bought as add-ons.'))),), + ('only', mark_safe('{}   {}'.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('{}   {}'.format( + _('Combined 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 = 'addon' if self.instance.is_addon else (self.instance.cross_selling_mode or 'normal') - #self.fields['show_in_cross_selling'] = BooleanField(widget=CheckboxInput(attrs={ - # 'data-display-dependency': '#id_category_type_0', - #}), - # help_text='Products are additionally shown in the cross-selling step, according to the configuration below') + 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:] diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 0a2c3f1fa..a22e6e56d 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -55,7 +55,6 @@ 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 ( @@ -94,7 +93,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, + _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 @@ -491,8 +491,10 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): if not hasattr(request, '_checkoutflow_addons_applicable'): cart = get_cart(request) self.request = request - request._checkoutflow_addons_applicable =('/addons/' in request.path_info or cart.filter(item__addons__isnull=False).exists() - or any(self.cross_selling_applicable_categories)) + 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 @@ -651,7 +653,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): allow_cross_sell=True, memberships=( self.request.customer.usable_memberships( - for_event=self.request.event, #p.subevent or self.request.event, + for_event=subevent or self.request.event, testmode=self.request.event.testmode ) if self.request.customer else None @@ -659,7 +661,6 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): ) for item in items: - print("-> discount info: ",item, discount_info.get(item.pk)) if item.pk in discount_info: (max_count, discount_rule) = discount_info[item.pk] diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 7b7cdc29f..c91d378f0 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -227,6 +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, warn_if_empty=True): """ Parses the POST data and returns a list of dictionaries @@ -247,7 +248,7 @@ def _items_from_post_data(request, warn_if_empty=True): 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') + voucher_ignore_if_redeemed=request.POST.get('_voucher_ignore_if_redeemed') == 'on') except CartError as e: messages.error(request, str(e)) return