diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index e999debe99..85e274bb41 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -441,7 +441,11 @@ class ItemCategorySerializer(I18nAwareModelSerializer): class Meta: model = ItemCategory - fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon', 'cross_selling_mode', 'cross_selling_condition') + fields = ( + 'id', 'name', 'internal_name', 'description', 'position', + 'is_addon', 'cross_selling_mode', + 'cross_selling_condition', 'cross_selling_match_products' + ) class QuestionOptionSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 9d6f54398c..1ad91ed540 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -142,6 +142,18 @@ class ItemCategory(LoggedModel): verbose_name_plural = _("Product categories") ordering = ('position', 'id') + def cross_sell_visible(self, cart_positions): + if self.cross_selling_mode is None: + return False + if self.cross_selling_condition == 'always': + return True + if self.cross_selling_condition == 'products': + match = set(match.pk for match in self.cross_selling_match_products.only('pk')) # TODO prefetch this + return any(pos.item.pk in match for pos in cart_positions) + if self.cross_selling_condition == 'discounts': + # TODO not sure how to do this yet + return False + def __str__(self): name = self.internal_name or self.name if self.is_addon: @@ -295,7 +307,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 @@ -316,6 +328,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: @@ -329,8 +343,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__): @@ -338,8 +352,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 227c2f479e..4e87c134cb 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1610,7 +1610,7 @@ 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: """ Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to @@ -1635,6 +1635,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/control/forms/item.py b/src/pretix/control/forms/item.py index a1b992b972..93173b1ec4 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.core.files.uploadedfile import UploadedFile +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 @@ -121,7 +122,12 @@ class CategoryForm(I18nModelForm): 'data-display-dependency': '#id_cross_selling_condition_1' } ) - self.fields['cross_selling_match_products'].queryset = self.event.items.all() + 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() diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index a0afcb8ca1..341ebcefe3 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -93,7 +93,7 @@ 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, + cart_session, create_empty_cart_id, get_or_create_cart_id, _items_from_post_data, ) from pretix.presale.views.event import get_grouped_items from pretix.presale.views.questions import QuestionsViewMixin @@ -488,9 +488,17 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): 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() + cart = get_cart(request) + self.request = request + request._checkoutflow_addons_applicable = (cart.filter(item__addons__isnull=False).exists() + or any(self.cross_selling_applicable_rules)) return request._checkoutflow_addons_applicable + @cached_property + def cross_selling_applicable_rules(self): + cart = self.get_cart(self.request) + return [c for c in self.request.event.categories.filter(cross_selling_mode__isnull=False) if c.cross_sell_visible(cart['positions'])] + def is_completed(self, request, warn=False): if getattr(self, '_completed', None) is not None: return self._completed @@ -605,9 +613,48 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): formset.append(formsetentry) return formset + def get_cross_selling_data(self, ctx): + class DummyCategory: + def __init__(self, rule, subevent=None): + self.id = rule.id + self.name = rule.name + (f" ({subevent})" if subevent else "") + self.description = rule.description + + if self.event.has_subevents: + return [ + (DummyCategory(rule, subevent), self._items_from_rule(rule, subevent), f'subevent_{subevent.pk}_') + for rule in self.cross_selling_applicable_rules + for subevent in set(pos.subevent for pos in ctx['cart']['positions']) + ] + else: + return [ + (rule, self._items_from_rule(rule, None)) + for rule in self.cross_selling_applicable_rules + ] + + def _items_from_rule(self, rule, subevent): + items, _btn = get_grouped_items( + self.request.event, + subevent=subevent, + voucher=None, + channel=self.request.sales_channel.identifier, + base_qs=rule.items.all(), + allow_addons=True, + allow_cross_sell=True, + memberships=( + self.request.customer.usable_memberships( + for_event=self.request.event, #p.subevent or self.request.event, + testmode=self.request.event.testmode + ) + if self.request.customer else None + ), + ) + return items + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['forms'] = self.forms + ctx['cross_selling_data'] = self.get_cross_selling_data(ctx) ctx['cart'] = self.get_cart() return ctx @@ -687,7 +734,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 +744,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 +752,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) + + 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 8b6f3d3507..47c96db8e3 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html @@ -46,6 +46,9 @@ {% endfor %} + + {% include "pretixpresale/event/fragment_product_list.html" with items_by_category=cross_selling_data ev=event %} +
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): + """ + 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: + messages.warning(request, _('You did not select any products.')) + return [] + return items @scopes_disabled() @@ -542,7 +543,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 4bc9d24c90..a3b4604c6d 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -109,7 +109,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 @@ -191,7 +192,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(