diff --git a/doc/api/resources/categories.rst b/doc/api/resources/categories.rst index ec8613d87..715907698 100644 --- a/doc/api/resources/categories.rst +++ b/doc/api/resources/categories.rst @@ -23,6 +23,22 @@ position integer An integer, use is_addon boolean If ``true``, items within this category are not on sale on their own but the category provides a source for defining add-ons for other products. +cross_selling_mode string If ``null``, cross-selling is disabled for this category. + If ``"only"``, it is only visible in the cross-selling + step. + If ``"both"``, it is visible on the normal index page + as well. + Only available if ``is_addon`` is ``false``. +cross_selling_condition string Only relevant if ``cross_selling_mode`` is not ``null``. + If ``"always"``, always show in cross-selling step. + If ``"products"``, only show if the cart contains one of + the products listed in ``cross_selling_match_products``. + If ``"discounts"``, only show products that qualify for + a discount according to discount rules. +cross_selling_match_products list of integer Only relevant if ``cross_selling_condition`` is + ``"products"``. Internal ID of the items of which at + least one needs to be in the cart for this category to + be shown. ===================================== ========================== ======================================================= @@ -60,7 +76,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } ] } @@ -102,7 +121,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } :param organizer: The ``slug`` field of the organizer to fetch @@ -130,7 +152,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } **Example response**: @@ -147,7 +172,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } :param organizer: The ``slug`` field of the organizer of the event to create a category for @@ -193,7 +221,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": true + "is_addon": true, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } :param organizer: The ``slug`` field of the organizer to modify diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 988e80962..053481014 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -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): diff --git a/src/pretix/base/migrations/0271_itemcategory_cross_selling.py b/src/pretix/base/migrations/0271_itemcategory_cross_selling.py new file mode 100644 index 000000000..43c8c4416 --- /dev/null +++ b/src/pretix/base/migrations/0271_itemcategory_cross_selling.py @@ -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" + ), + ), + ] diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index d547de5c7..be2b3d0ac 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -20,11 +20,11 @@ # . # -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 diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 8a02cf5eb..7b2bec1e7 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -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() diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 04af18500..402e5e926 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -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): diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 5b6ebe325..3e8e5aada 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -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() diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py new file mode 100644 index 000000000..95ec3d26f --- /dev/null +++ b/src/pretix/base/services/cross_selling.py @@ -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 . +# +# 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 +# . +# + +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, 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 diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index acfdc44b0..8fbd024af 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -20,8 +20,9 @@ # . # 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) diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index a2e07f4df..c11e6b3f5 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -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 = '{}   {}' + 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): diff --git a/src/pretix/control/templates/pretixcontrol/items/categories.html b/src/pretix/control/templates/pretixcontrol/items/categories.html index 753f067a3..dc0035d6b 100644 --- a/src/pretix/control/templates/pretixcontrol/items/categories.html +++ b/src/pretix/control/templates/pretixcontrol/items/categories.html @@ -31,6 +31,7 @@ {% trans "Product categories" %} + {% trans "Category type" %} @@ -40,6 +41,9 @@ {{ c.internal_name|default:c.name }} + + {{ c.get_category_type_display }} + diff --git a/src/pretix/control/templates/pretixcontrol/items/category.html b/src/pretix/control/templates/pretixcontrol/items/category.html index b2b9c16ec..0aa8c703a 100644 --- a/src/pretix/control/templates/pretixcontrol/items/category.html +++ b/src/pretix/control/templates/pretixcontrol/items/category.html @@ -16,7 +16,9 @@ {% bootstrap_field form.internal_name layout="control" %} {% 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" %} {% if category %} diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 7128e1873..781c4167f 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -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)) diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html index 8b6f3d350..d4263fb5c 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html @@ -6,9 +6,12 @@ {% load money %} {% load thumb %} {% block inner %} -

- {% trans "For some of the products in your cart, you can choose additional options before you continue." %} -

+ + {% if forms %} +

+ {% trans "For some of the products in your cart, you can choose additional options before you continue." %} +

+ {% endif %}
{% csrf_token %} @@ -17,10 +20,12 @@

- {% trans "Add-ons:" %} - {{ form.item.name }}{% if form.variation %} - – {{ form.variation }} - {% endif %} + + {% trans "Additional options for" %} + {{ form.item.name }}{% if form.variation %} + – {{ form.variation }} + {% endif %} +

@@ -46,6 +51,20 @@
{% endfor %} + + {% if cross_selling_data %} +
+ +

+ {% trans "More recommendations" %} +

+
+
+ {% include "pretixpresale/event/fragment_product_list.html" with items_by_category=cross_selling_data ev=event %} +
+
+ {% endif %} +
-

{{ tup.0.name }}

- {% if tup.0.description %} -
{{ tup.0.description|localize|rich_text }}
+{% for tup in items_by_category %}{% with category=tup.0 items=tup.1 form_prefix=tup.2 %} + {% if category %} +
+

{{ category.name }} + {% if category.subevent_name %} + {{ category.subevent_name }} + {% endif %} + {% if category.category_has_discount %} + + + Congratulations! + {% trans "Your order qualifies for a discount" %} + + {% endif %} +

+ {% if category.description %} +
{{ category.description|localize|rich_text }}
{% endif %} {% else %} -
-

{% trans "Uncategorized items" %}

+
+

{% trans "Uncategorized items" %}

{% endif %} - {% for item in tup.1 %} + {% for item in items %} {% if item.has_variations %} -
+
{% if item.picture %} @@ -32,9 +43,9 @@ {% endif %}
-

{{ item.name }}

+

{{ item.name }}

{% if item.description %} -
+
{{ item.description|localize|rich_text }}
{% endif %} @@ -101,14 +112,14 @@
{% for var in item.available_variations %} -
-
{{ var }}
+
{{ var }}
{% if var.description %} -
+
{{ var.description|localize|rich_text }}
{% endif %} @@ -139,11 +150,11 @@
{{ event.currency }} + {% if var.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description"{% endif %}> {% trans "Select" context "checkbox" %} {% else %}
- -
{% endif %} @@ -234,7 +245,7 @@
{% else %} -
@@ -250,9 +261,9 @@ {% endif %}
-

{{ item.name }}

+

{{ item.name }}

{% if item.description %} -
+
{{ item.description|localize|rich_text }}
{% endif %} @@ -293,10 +304,10 @@ + {% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.id }}-description"{% endif %}> {% trans "Select" context "checkbox" %} {% else %}
- -
{% endif %} @@ -386,4 +397,4 @@ {% endif %} {% endfor %}
-{% endfor %} +{% endwith %}{% endfor %} diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index ce29cbfdd..77efffaa6 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -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( diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index be92d18a7..ddc144cd5 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -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, diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 0cd516745..d0f9a25b4 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -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( diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index ddccaf635..a83ae15f2 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -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 () { diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index c557d3d52..fbe1e1de2 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -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; diff --git a/src/pretix/static/pretixpresale/scss/_checkout.scss b/src/pretix/static/pretixpresale/scss/_checkout.scss index 09bd7d2ef..b0cd8a0fa 100644 --- a/src/pretix/static/pretixpresale/scss/_checkout.scss +++ b/src/pretix/static/pretixpresale/scss/_checkout.scss @@ -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 { diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 5cd0d4b71..3d440e74d 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -128,7 +128,10 @@ TEST_CATEGORY_RES = { "description": {"en": ""}, "internal_name": None, "position": 0, - "is_addon": False + "is_addon": False, + "cross_selling_mode": None, + "cross_selling_condition": None, + "cross_selling_match_products": [], } @@ -211,6 +214,44 @@ def test_category_update(token_client, organizer, event, team, category): assert ItemCategory.objects.get(pk=category.pk).name == {"en": "Test"} +@pytest.mark.django_db +def test_category_update_cross_selling_options(token_client, organizer, event, team, category): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/categories/{}/'.format(organizer.slug, event.slug, category.pk), + { + "cross_selling_mode": "both", + }, + format='json' + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert ItemCategory.objects.get(pk=category.pk).cross_selling_mode == 'both' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/categories/{}/'.format(organizer.slug, event.slug, category.pk), + { + "cross_selling_mode": "something", + }, + format='json' + ) + assert resp.status_code == 400 + with scopes_disabled(): + assert ItemCategory.objects.get(pk=category.pk).cross_selling_mode == 'both' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/categories/{}/'.format(organizer.slug, event.slug, category.pk), + { + "is_addon": True, + }, + format='json' + ) + assert resp.status_code == 400 + assert 'mutually exclusive' in str(resp.data) + with scopes_disabled(): + assert ItemCategory.objects.get(pk=category.pk).cross_selling_mode == 'both' + assert ItemCategory.objects.get(pk=category.pk).is_addon is False + + @pytest.mark.django_db def test_category_update_wrong_event(token_client, organizer, event2, category): resp = token_client.patch( diff --git a/src/tests/base/test_cross_selling.py b/src/tests/base/test_cross_selling.py new file mode 100644 index 000000000..93d1e4896 --- /dev/null +++ b/src/tests/base/test_cross_selling.py @@ -0,0 +1,897 @@ +# +# 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 . +# +# 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 +# . +# +import datetime +import re +from decimal import Decimal +from typing import List, Tuple + +import pytest +from django.utils.timezone import now +from django_scopes import scopes_disabled +from freezegun import freeze_time +from tests import assert_num_queries + +from pretix.base.models import CartPosition, Discount, Event, Organizer +from pretix.base.services.cross_selling import CrossSellingService + + +@pytest.fixture +def event(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now() + ) + return event + + +@pytest.fixture +@freeze_time("2020-01-01 10:00:00+01:00") +def eventseries(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + start = now() + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=start, has_subevents=True + ) + event.subevents.create(name='Date1', date_from=start + datetime.timedelta(hours=1), active=True) + event.subevents.create(name='Date2', date_from=start + datetime.timedelta(hours=2), active=True) + event.subevents.create(name='Date3', date_from=start + datetime.timedelta(hours=3), active=True) + return event + + +def pattern(regex, **kwargs): + return re.compile(regex), kwargs + + +cond_suffix = [ + pattern(r" in the same subevent$", subevent_mode=Discount.SUBEVENT_MODE_SAME), + pattern(r" in distinct subevents$", subevent_mode=Discount.SUBEVENT_MODE_DISTINCT), +] +cond_patterns = [ + pattern(r"^Buy at least (?P\d+) of (?P.+)$", + condition_all_products=False), + pattern(r"^Buy at least (?P\d+) products$", + condition_all_products=True), + pattern(r"^Spend at least (?P\d+)\$$", + condition_all_products=True), + pattern(r"^For every (?P\d+) of (?P.+)$", + condition_all_products=False), + pattern(r"^For every (?P\d+) products$", + condition_all_products=True), +] +benefit_patterns = [ + pattern(r"^get (?P\d+)% discount on them\.$", + benefit_same_products=True), + pattern(r"^get (?P\d+)% discount on everything\.$", + benefit_same_products=True), + pattern(r"^get (?P\d+)% discount on " + r"(?P\d+) of them\.$", + benefit_same_products=True), + pattern(r"^get (?P\d+)% discount on " + r"(?P\d+) of (?P.+)\.$", + benefit_same_products=False), + pattern(r"^get (?P\d+)% discount on " + r"(?P.+)\.$", + benefit_same_products=False), +] + + +def make_discount(description, event: Event): + condition, benefit = description.split(', ') + + d = Discount(event=event, internal_name=description) + d.save() + + def apply(patterns: List[Tuple[re.Pattern, dict]], input): + for regex, options in patterns: + m = regex.search(input) + if m: + fields = m.groupdict() + for k, v in [*fields.items(), *options.items()]: + if '_limit_products' in k: + getattr(d, k).set([event.items.get(name=v)]) + else: + setattr(d, k, v) + input = input[:m.start(0)] + input[m.endpos:] + if input != '': + raise Exception("Unable to parse '{}'".format(input)) + + apply(cond_suffix + cond_patterns, condition) + apply(benefit_patterns, benefit) + + d.full_clean() + d.save() + return d + + +def validate_discount_rule( + d, + subevent_mode=Discount.SUBEVENT_MODE_MIXED, + condition_all_products=True, + condition_limit_products=[], + condition_apply_to_addons=True, + condition_ignore_voucher_discounted=False, + condition_min_count=0, + condition_min_value=Decimal('0.00'), + benefit_same_products=True, + benefit_limit_products=[], + benefit_discount_matching_percent=Decimal('0.00'), + benefit_only_apply_to_cheapest_n_matches=None, + benefit_apply_to_addons=True, + benefit_ignore_voucher_discounted=False): + assert d.subevent_mode == subevent_mode + assert d.condition_all_products == condition_all_products + assert [str(p.name) for p in d.condition_limit_products.all()] == condition_limit_products + assert d.condition_apply_to_addons == condition_apply_to_addons + assert d.condition_ignore_voucher_discounted == condition_ignore_voucher_discounted + assert d.condition_min_count == condition_min_count + assert d.condition_min_value == condition_min_value + assert d.benefit_same_products == benefit_same_products + assert [str(p.name) for p in d.benefit_limit_products.all()] == benefit_limit_products + assert d.benefit_discount_matching_percent == benefit_discount_matching_percent + assert d.benefit_only_apply_to_cheapest_n_matches == benefit_only_apply_to_cheapest_n_matches + assert d.benefit_apply_to_addons == benefit_apply_to_addons + assert d.benefit_ignore_voucher_discounted == benefit_ignore_voucher_discounted + return d + + +@scopes_disabled() +@pytest.mark.django_db +def test_rule_parser(event): + # mixed_min_count_matching_percent + validate_discount_rule( + make_discount("Buy at least 3 products, get 20% discount on everything.", event), + subevent_mode=Discount.SUBEVENT_MODE_MIXED, + condition_min_count=3, + benefit_discount_matching_percent=Decimal('20.00') + ) + + # mixed_min_count_one_free + validate_discount_rule( + make_discount("For every 3 products, get 100% discount on 1 of them.", event), + subevent_mode=Discount.SUBEVENT_MODE_MIXED, + condition_min_count=3, + benefit_discount_matching_percent=Decimal('100.00'), + benefit_only_apply_to_cheapest_n_matches=1, + ) + + # mixed_min_value_matching_percent + validate_discount_rule( + make_discount("Spend at least 500$, get 20% discount on everything.", event), + subevent_mode=Discount.SUBEVENT_MODE_MIXED, + condition_min_value=Decimal('500.00'), + benefit_discount_matching_percent=Decimal('20.00') + ) + + # same_min_count_matching_percent + validate_discount_rule( + make_discount("Buy at least 3 products in the same subevent, get 20% discount on everything.", event), + subevent_mode=Discount.SUBEVENT_MODE_SAME, + condition_min_count=3, + benefit_discount_matching_percent=Decimal('20.00') + ) + + # same_min_count_one_free + validate_discount_rule( + make_discount("For every 3 products in the same subevent, get 100% discount on 1 of them.", event), + subevent_mode=Discount.SUBEVENT_MODE_SAME, + condition_min_count=3, + benefit_discount_matching_percent=Decimal('100.00'), + benefit_only_apply_to_cheapest_n_matches=1, + ) + + # same_min_value_matching_percent + validate_discount_rule( + make_discount("Spend at least 500$ in the same subevent, get 20% discount on everything.", event), + subevent_mode=Discount.SUBEVENT_MODE_SAME, + condition_min_value=Decimal('500.00'), + benefit_discount_matching_percent=Decimal('20.00') + ) + + # distinct_min_count_matching_percent + validate_discount_rule( + make_discount("Buy at least 3 products in distinct subevents, get 20% discount on everything.", event), + subevent_mode=Discount.SUBEVENT_MODE_DISTINCT, + condition_min_count=3, + benefit_discount_matching_percent=Decimal('20.00') + ) + + # distinct_min_count_one_free + validate_discount_rule( + make_discount("For every 3 products in distinct subevents, get 100% discount on 1 of them.", event), + subevent_mode=Discount.SUBEVENT_MODE_DISTINCT, + condition_min_count=3, + benefit_discount_matching_percent=Decimal('100.00'), + benefit_only_apply_to_cheapest_n_matches=1, + ) + + # distinct_min_count_two_free + validate_discount_rule( + make_discount("For every 3 products in distinct subevents, get 100% discount on 2 of them.", event), + subevent_mode=Discount.SUBEVENT_MODE_DISTINCT, + condition_min_count=3, + benefit_discount_matching_percent=Decimal('100.00'), + benefit_only_apply_to_cheapest_n_matches=2, + ) + + +def setup_items(event, category_name, category_type, cross_selling_condition, *items): + cat = event.categories.create(name=category_name) + cat.category_type = category_type + cat.cross_selling_condition = cross_selling_condition + cat.save() + for name, price in items: + item = cat.items.create(event=event, name=name, default_price=price) + for subevent in event.subevents.all() if event.has_subevents else [None]: + quota = event.quotas.create(subevent=subevent) + quota.items.add(item) + quota.save() + + +def split_table(txt): + return [ + re.split(r"\s{3,}", line.strip()) + for line in txt.split("\n")[1:] + if line.strip() != "" + ] + + +def check_cart_behaviour(event, cart_contents, recommendations, expect_num_queries=None): + cart_contents = split_table(cart_contents) + subevent_map = {str(se.name): se.pk for se in event.subevents.all()} + positions = [ + CartPosition( + item_id=event.items.get(name=item_name).pk, + subevent_id=subevent_map.get(subevent_name), + line_price_gross=Decimal(regular_price), addon_to=None, is_bundled=False, + listed_price=Decimal(regular_price), price_after_voucher=Decimal(regular_price) + ) for (item_name, regular_price, expected_discounted_price, subevent_name) in cart_contents + ] + expected_recommendations = split_table(recommendations) + + event.organizer.cache.clear() + event.cache.clear() + event.refresh_from_db() + service = CrossSellingService(event, event.organizer.sales_channels.get(identifier='web'), positions, None) + if expect_num_queries: + with assert_num_queries(expect_num_queries): + result = service.get_data() + else: + result = service.get_data() + result_recommendations = [ + [str(category.name) + (f' ({category.subevent_name})' if hasattr(category, 'subevent_name') else ''), + str(item.name), + str(item.original_price.gross.quantize(Decimal('0.00'))) if item.original_price else '-', + str(item.display_price.gross.quantize(Decimal('0.00'))), + str(item.order_max), + form_prefix or '-'] + for category, items, form_prefix in result + for item in items + ] + + assert result_recommendations == expected_recommendations + assert [str(price) for price, discount in service._discounted_prices] == [ + expected_discounted_price for (item_name, regular_price, expected_discounted_price, form_prefix) in cart_contents] + + +@scopes_disabled() +@pytest.mark.django_db +def test_2f1r_discount_cross_selling(event): + setup_items(event, 'Tickets', 'both', 'discounts', + ('Regular Ticket', '42.00'), + ('Reduced Ticket', '23.00'), + ) + make_discount('For every 2 of Regular Ticket, get 50% discount on 1 of Reduced Ticket.', event) + + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Tickets Reduced Ticket 23.00 11.50 1 - + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + + Reduced Ticket 23.00 11.50 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + + Reduced Ticket 23.00 11.50 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Tickets Reduced Ticket 23.00 11.50 2 - + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + + Reduced Ticket 23.00 11.50 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Tickets Reduced Ticket 23.00 11.50 1 - + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + + Reduced Ticket 23.00 11.50 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Tickets Reduced Ticket 23.00 11.50 1 - + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + + Reduced Ticket 23.00 11.50 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Tickets Reduced Ticket 23.00 11.50 2 - + ''' + ) + + +@scopes_disabled() +@pytest.mark.django_db +@freeze_time("2020-01-01 10:00:00+01:00") +def test_2f1r_discount_cross_selling_eventseries_mixed(eventseries): + setup_items(eventseries, 'Tickets', 'both', 'discounts', + ('Regular Ticket', '42.00'), + ('Reduced Ticket', '23.00'), + ) + make_discount('For every 2 of Regular Ticket, get 50% discount on 1 of Reduced Ticket.', eventseries) + prefix_date1 = f"subevent_{eventseries.subevents.get(name='Date1').pk}_" + prefix_date2 = f"subevent_{eventseries.subevents.get(name='Date2').pk}_" + + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1} + Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 1 {prefix_date2} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + Regular Ticket 42.00 42.00 Date1 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 2 {prefix_date1} + Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 2 {prefix_date2} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 2 {prefix_date1} + ''' + ) + + +@scopes_disabled() +@pytest.mark.django_db +def test_2f1r_discount_cross_selling_eventseries_same(eventseries): + setup_items(eventseries, 'Tickets', 'both', 'discounts', + ('Regular Ticket', '42.00'), + ('Reduced Ticket', '23.00'), + ) + make_discount('For every 2 of Regular Ticket in the same subevent, get 50% discount on 1 of Reduced Ticket.', eventseries) + prefix_date1 = f"subevent_{eventseries.subevents.get(name='Date1').pk}_" + prefix_date2 = f"subevent_{eventseries.subevents.get(name='Date2').pk}_" + + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1} + Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 1 {prefix_date2} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + Regular Ticket 42.00 42.00 Date2 + Regular Ticket 42.00 42.00 Date2 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1} + Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 2 {prefix_date2} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1} + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date2 + Regular Ticket 42.00 42.00 Date2 + + Reduced Ticket 23.00 11.50 Date1 + ''', + recommendations=f''' Price Discounted Price Max Count Prefix + Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 1 {prefix_date2} + ''' + ) + + +@scopes_disabled() +@pytest.mark.django_db +def test_50percentoff_discount_cross_selling_eventseries_distinct(eventseries): + setup_items(eventseries, 'Tickets', 'both', 'discounts', + ('Regular Ticket', '42.00'), + ('Reduced Ticket', '23.00'), + ) + make_discount('For every 2 of Regular Ticket in distinct subevents, get 50% discount on them.', eventseries) + + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + Regular Ticket 42.00 42.00 Date1 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 21.00 Date1 + Regular Ticket 42.00 21.00 Date2 + Regular Ticket 42.00 21.00 Date3 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + eventseries, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 21.00 Date1 + Regular Ticket 42.00 21.00 Date2 + Regular Ticket 42.00 21.00 Date3 + Reduced Ticket 23.00 23.00 Date1 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + + +@scopes_disabled() +@pytest.mark.django_db +@pytest.mark.skip("currently unsupported (cannot give discount to specific product on minimum cart value)") +def test_free_drinks(event): + setup_items(event, 'Tickets', 'normal', None, + ('Regular Ticket', '42.00'), + ('Reduced Ticket', '23.00'), + ) + setup_items(event, 'Free Drinks', 'only', 'discounts', + ('Free Drinks', '50.00'), + ) + make_discount('Spend at least 100$, get 100% discount on 1 of Free Drinks.', event) + + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Free Drinks Free Drinks 50.00 0.00 1 - + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Free Drinks 50.00 0.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + + +@scopes_disabled() +@pytest.mark.django_db +def test_five_tickets_one_free(event): + setup_items(event, 'Tickets', 'both', 'discounts', + ('Regular Ticket', '42.00'), + ) + make_discount('For every 5 of Regular Ticket, get 100% discount on 1 of them.', event) + # we don't expect a recommendation here, as in the current implementation we only recommend based on discounts + # where the condition is already completely satisfied but no (or not enough) benefitting products are in the + # cart yet + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 0.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 42.00 0 + Regular Ticket 42.00 0.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''' + ) + + +@scopes_disabled() +@pytest.mark.django_db +@pytest.mark.parametrize("itemcount", [3, 10, 50]) +def test_query_count_many_items(event, itemcount): + setup_items(event, 'Tickets', 'both', 'discounts', + *[(f'Ticket {n}', '42.00') for n in range(itemcount)] + ) + make_discount('For every 5 of Ticket 1, get 100% discount on 1 of Ticket 2.', event) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''', + expect_num_queries=8, + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Tickets Ticket 2 42.00 0.00 1 - + ''', + expect_num_queries=9, + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + Ticket 1 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Tickets Ticket 2 42.00 0.00 1 - + ''', + expect_num_queries=9, + ) + + +@scopes_disabled() +@pytest.mark.django_db +@pytest.mark.parametrize("catcount", [1, 10, 50]) +def test_query_count_many_categories_and_discounts(event, catcount): + for n in range(1, catcount + 1): + setup_items(event, f'Category {n}', 'both', 'discounts', + (f'Ticket {n}-A', '42.00'), + (f'Ticket {n}-B', '42.00'), + ) + make_discount(f'For every 5 of Ticket {n}-A, get 100% discount on 1 of Ticket {n}-B.', event) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''', + expect_num_queries=8, + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Category 1 Ticket 1-B 42.00 0.00 1 - + ''', + expect_num_queries=9, + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Category 1 Ticket 1-B 42.00 0.00 1 - + ''', + expect_num_queries=9, + ) + + +@scopes_disabled() +@pytest.mark.django_db +@pytest.mark.parametrize("catcount", [2, 10, 50]) +def test_query_count_many_cartpos(event, catcount): + for n in range(1, catcount + 1): + setup_items(event, f'Category {n}', 'both', 'discounts', + (f'Ticket {n}-A', '42.00'), + (f'Ticket {n}-B', '42.00'), + ) + make_discount(f'For every 5 of Ticket {n}-A, get 100% discount on 1 of Ticket {n}-B.', event) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + ''', + expect_num_queries=8, + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Category 1 Ticket 1-B 42.00 0.00 1 - + ''', + expect_num_queries=9, + ) + check_cart_behaviour( + event, + cart_contents=''' Price Discounted Subev + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 1-A 42.00 42.00 0 + Ticket 2-A 42.00 42.00 0 + Ticket 2-A 42.00 42.00 0 + Ticket 2-A 42.00 42.00 0 + Ticket 2-A 42.00 42.00 0 + Ticket 2-A 42.00 42.00 0 + ''', + recommendations=''' Price Discounted Price Max Count Prefix + Category 1 Ticket 1-B 42.00 0.00 1 - + Category 2 Ticket 2-B 42.00 0.00 1 - + ''', + expect_num_queries=13, + ) diff --git a/src/tests/base/test_event_clone.py b/src/tests/base/test_event_clone.py index 4099342af..aa235549c 100644 --- a/src/tests/base/test_event_clone.py +++ b/src/tests/base/test_event_clone.py @@ -65,6 +65,8 @@ def test_full_clone_same_organizer(): item_meta = event.item_meta_properties.create(name="Bla") tax_rule = event.tax_rules.create(name="VAT", rate=19) category = event.categories.create(name="Tickets") + cross_sell_category = event.categories.create(name="Recommendations", cross_selling_mode="only", + cross_selling_condition="products") q1 = event.quotas.create(name="Quota 1", size=5) q2 = event.quotas.create(name="Quota 2", size=0, closed=True) @@ -88,6 +90,7 @@ def test_full_clone_same_organizer(): q1.items.add(item1) q2.items.add(item2) q2.variations.add(item2v) + cross_sell_category.cross_selling_match_products.add(item1) event.discounts.create(internal_name="Fake discount") question1 = event.questions.create(question="Yes or no", type=Question.TYPE_BOOLEAN) @@ -156,7 +159,7 @@ def test_full_clone_same_organizer(): copied_item1 = copied_event.items.get(name=item1.name) copied_item2 = copied_event.items.get(name=item2.name) assert copied_item1.tax_rule == copied_event.tax_rules.get() - assert copied_item1.category == copied_event.categories.get() + assert copied_item1.category == copied_event.categories.get(name='Tickets') assert copied_item1.limit_sales_channels.get() == sc assert copied_item1.meta_data == item1.meta_data assert copied_item2.variations.get().meta_data == item2v.meta_data @@ -166,7 +169,7 @@ def test_full_clone_same_organizer(): assert copied_item2.variations.get().limit_sales_channels.get() == sc assert copied_item2.require_membership_types.count() == 1 assert copied_item2.require_membership_types.get() == membership_type - assert copied_item1.addons.get().addon_category == copied_event.categories.get() + assert copied_item1.addons.get().addon_category == copied_event.categories.get(name='Tickets') assert copied_item1.bundles.get().bundled_item == copied_item2 assert copied_item1.bundles.get().bundled_variation == copied_item2.variations.get() assert copied_item2.hidden_if_item_available == copied_item1 @@ -174,6 +177,9 @@ def test_full_clone_same_organizer(): assert copied_q2.items.get() == copied_item2 assert copied_q2.variations.get() == copied_item2.variations.get() + copied_cross_sell_category = copied_event.categories.get(name=cross_sell_category.name) + assert copied_cross_sell_category.cross_selling_match_products.get() == copied_item1 + copied_question1 = copied_event.questions.get(type=question1.type) copied_question2 = copied_event.questions.get(type=question2.type) assert copied_question2.dependency_question == copied_question1