From df656d1580daec927fe53beb6e5b18e13034d6e4 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 27 Sep 2024 13:41:53 +0200 Subject: [PATCH] Avoid showing empty add-on step (Z#23167007) Do not show add-on step if none of the add-on items is available. This is not a perfect solution, there are still cases where an empty add-on step will show up, e.g.: - Products disabled only for a specific subevent - Sold-out add-ons combined with the "hide sold-out products" flag --- src/pretix/base/models/event.py | 35 ++---------------- src/pretix/base/models/items.py | 58 +++++++++++++++++++++++++++++- src/pretix/presale/checkoutflow.py | 28 +++++++++++++-- 3 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 8a02cf5ebe..8aefce59b3 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -313,38 +313,9 @@ class EventMixin: items=GroupConcat('pk', delimiter=',') ).values('items') - q_variation = ( - Q(active=True) - & Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now())) - & Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now())) - & Q(item__active=True) - & Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now())) - & Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now())) - & Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False)) - & Q(item__require_bundling=False) - & Q(quotas__pk=OuterRef('pk')) - ) - - if isinstance(channel, str): - q_variation &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel)) - q_variation &= Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels__identifier=channel)) - else: - q_variation &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels=channel)) - q_variation &= Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels=channel)) - - if voucher: - if voucher.variation_id: - q_variation &= Q(pk=voucher.variation_id) - elif voucher.item_id: - q_variation &= Q(item_id=voucher.item_id) - elif voucher.quota_id: - q_variation &= Q(quotas__in=[voucher.quota_id]) - - if not voucher or not voucher.show_hidden_items: - q_variation &= Q(hide_without_voucher=False) - q_variation &= Q(item__hide_without_voucher=False) - - sq_active_variation = ItemVariation.objects.filter(q_variation).order_by().values_list('quotas__pk').annotate( + sq_active_variation = ItemVariation.objects.filter_available(channel=channel, voucher=voucher).filter( + Q(quotas__pk=OuterRef('pk')) + ).order_by().values_list('quotas__pk').annotate( items=GroupConcat('pk', delimiter=',') ).values('items') quota_base_qs = Quota.objects.using(settings.DATABASE_REPLICA).filter( diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 04af185003..33862d12da 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -303,6 +303,48 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False): return qs.filter(q) +def filter_variations_available(qs, channel='web', voucher=None, allow_addons=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 + + assert isinstance(channel, (SalesChannel, str)) + q = ( + Q(active=True) + & Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now())) + & Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now())) + & Q(item__active=True) + & Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now())) + & Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now())) + & Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False)) + & Q(item__require_bundling=False) + ) + + if isinstance(channel, str): + q &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel)) + q &= Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels__identifier=channel)) + else: + q &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels=channel)) + q &= Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels=channel)) + + if not allow_addons: + q &= Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False)) + + if voucher: + if voucher.variation_id: + q &= Q(pk=voucher.variation_id) + elif voucher.item_id: + q &= Q(item_id=voucher.item_id) + elif voucher.quota_id: + q &= Q(quotas__in=[voucher.quota_id]) + + if not voucher or not voucher.show_hidden_items: + q &= Q(hide_without_voucher=False) + q &= Q(item__hide_without_voucher=False) + + return qs.filter(q) + + class ItemQuerySet(models.QuerySet): def filter_available(self, channel='web', voucher=None, allow_addons=False): return filter_available(self, channel, voucher, allow_addons) @@ -317,6 +359,20 @@ class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__) return filter_available(self.get_queryset(), channel, voucher, allow_addons) +class ItemVariationQuerySet(models.QuerySet): + def filter_available(self, channel='web', voucher=None, allow_addons=False): + return filter_variations_available(self, channel, voucher, allow_addons) + + +class ItemVariationQuerySetManager(ScopedManager(organizer='item__event__organizer').__class__): + def __init__(self): + super().__init__() + self._queryset_class = ItemVariationQuerySet + + def filter_available(self, channel='web', voucher=None, allow_addons=False): + return filter_variations_available(self.get_queryset(), channel, voucher, allow_addons) + + class Item(LoggedModel): """ An item is a thing which can be sold. It belongs to an event and may or may not belong to a category. @@ -1199,7 +1255,7 @@ class ItemVariation(models.Model): help_text=_('This text will be shown by the check-in app if a ticket of this type is scanned.') ) - objects = ScopedManager(organizer='item__event__organizer') + objects = ItemVariationQuerySetManager() class Meta: verbose_name = _("Product variation") diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 7128e18730..ab98f58114 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -56,7 +56,7 @@ from django.views.generic.base import TemplateResponseMixin from django_scopes import scopes_disabled from pretix.base.models import Customer, Membership, Order -from pretix.base.models.items import Question +from pretix.base.models.items import ItemAddOn, ItemVariation, Question from pretix.base.models.orders import ( InvoiceAddress, OrderPayment, QuestionAnswer, ) @@ -486,9 +486,33 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): label = pgettext_lazy('checkoutflow', 'Add-on products') icon = 'puzzle-piece' + def _is_applicable(self, request): + categories = set(ItemAddOn.objects.filter( + base_item_id__in=get_cart(request).values_list("item_id", flat=True) + ).values_list("addon_category_id", flat=True)) + if not categories: + return False + + has_available_addons = ( + self.event.items.filter_available( + channel=request.sales_channel, + allow_addons=True + ).filter( + variations__isnull=True, + category__in=categories, + ).exists() or ItemVariation.objects.filter_available( + channel=request.sales_channel, + allow_addons=True + ).filter( + item__event=self.event, + item__category__in=categories, + ) + ) + return has_available_addons + 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() + request._checkoutflow_addons_applicable = self._is_applicable(request) return request._checkoutflow_addons_applicable def is_completed(self, request, warn=False):