diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py index f407d2fad1..bce23908ba 100644 --- a/src/pretix/base/services/cross_selling.py +++ b/src/pretix/base/services/cross_selling.py @@ -29,7 +29,7 @@ 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 +from pretix.presale.utils import get_grouped_items class DummyCategory: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index dc73363301..c689cdcb18 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -110,6 +110,7 @@ from pretix.celery_app import app from pretix.helpers import OF_SELF from pretix.helpers.models import modelcopy from pretix.helpers.periodic import minimum_interval +from pretix.presale.utils import get_grouped_items from pretix.testutils.middleware import debugflags_var @@ -2106,6 +2107,13 @@ class OrderChangeManager: for a in current_addons[cp][k][:current_num - input_num]: if a.canceled: continue + items, _ = get_grouped_items(self.order.event, + channel=self.order.sales_channel, + subevent=a.subevent, + has_voucher=True, + base_qs=Item.objects.filter(pk=a.item.pk), + allow_addons=True + ) is_unavailable = ( # If an item is no longer available due to time, it should usually also be no longer # user-removable, because e.g. the stock has already been ordered. @@ -2115,10 +2123,11 @@ class OrderChangeManager: # be part of the input. (a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent)) or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel)) - or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent) + or not items + or items[0].unavailability_reason(has_voucher=True, subevent=a.subevent) or ( - not a.item.all_sales_channels and - not a.item.limit_sales_channels.contains(self.order.sales_channel) + not items[0].all_sales_channels and + not items[0].limit_sales_channels.contains(self.order.sales_channel) ) ) if is_unavailable: diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 4256b07cd9..4cc0acce08 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -93,13 +93,12 @@ from pretix.presale.signals import ( order_api_meta_from_request, order_meta_from_request, question_form_fields, question_form_fields_overrides, ) -from pretix.presale.utils import customer_login +from pretix.presale.utils import customer_login, get_grouped_items from pretix.presale.views import CartMixin, get_cart, get_cart_is_free from pretix.presale.views.cart import ( _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 diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index d0703a439d..e4aa4a7697 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -32,6 +32,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. import re +import sys import time import warnings from importlib import import_module @@ -41,7 +42,10 @@ from django.conf import settings from django.contrib.auth import ( BACKEND_SESSION_KEY, SESSION_KEY, get_user_model, load_backend, ) -from django.db.models import Q +from django.db.models import ( + Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value, +) +from django.db.models.lookups import Exact from django.http import Http404, HttpResponseForbidden from django.middleware.csrf import rotate_token from django.template import loader @@ -54,14 +58,25 @@ from django.utils.translation import gettext_lazy as _ from django_scopes import scope from pretix.base.middleware import LocaleMiddleware -from pretix.base.models import Customer, Event, Organizer -from pretix.base.timemachine import time_machine_now_assigned_from_request +from pretix.base.models import ( + Customer, Event, ItemVariation, Organizer, Quota, SalesChannel, + SeatCategoryMapping, +) +from pretix.base.models.items import ( + Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, +) +from pretix.base.services.quotas import QuotaAvailability +from pretix.base.timemachine import ( + time_machine_now, time_machine_now_assigned_from_request, +) from pretix.helpers.http import redirect_to_url from pretix.multidomain.models import KnownDomain from pretix.multidomain.urlreverse import ( eventreverse_absolute, get_event_domain, get_organizer_domain, ) -from pretix.presale.signals import process_request, process_response +from pretix.presale.signals import ( + item_description, process_request, process_response, +) SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -480,3 +495,385 @@ def event_view(function=None, require_live=True): return fn return function or noop + + +def item_group_by_category(items): + return sorted( + [ + # a group is a tuple of a category and a list of items + (cat, [i for i in items if i.category == cat]) + for cat in set([i.category for i in items]) + # insert categories into a set for uniqueness + # a set is unsorted, so sort again by category + ], + key=lambda group: (group[0].position, group[0].id) if ( + group[0] is not None and group[0].id is not None) else (0, 0) + ) + + +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, has_voucher=False): + base_qs_set = base_qs is not None + base_qs = base_qs if base_qs is not None else event.items + + requires_seat = Exists( + SeatCategoryMapping.objects.filter( + product_id=OuterRef('pk'), + subevent=subevent + ) + ) + if not event.settings.seating_choice: + requires_seat = Value(0, output_field=IntegerField()) + + variation_q = ( + Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) & + Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info')) + ) + if not has_voucher and (not voucher or not voucher.show_hidden_items): + variation_q &= Q(hide_without_voucher=False) + + if memberships is not None: + prefetch_membership_types = ['require_membership_types'] + else: + prefetch_membership_types = [] + + prefetch_var = Prefetch( + 'variations', + to_attr='available_variations', + queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate( + subevent_disabled=Exists( + SubEventItemVariation.objects.filter( + Q(disabled=True) + | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now())) + | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())), + variation_id=OuterRef('pk'), + subevent=subevent, + ) + ), + ).filter( + variation_q, + Q(all_sales_channels=True) | Q(limit_sales_channels=channel), + active=True, + quotas__isnull=False, + subevent_disabled=False + ).prefetch_related( + *prefetch_membership_types, + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( + subevent=subevent).select_related("subevent")) + ).distinct() + ) + prefetch_quotas = Prefetch( + 'quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent).select_related("subevent") + ) + prefetch_bundles = Prefetch( + 'bundles', + queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related( + Prefetch('bundled_item', + queryset=event.items.using(settings.DATABASE_REPLICA).select_related( + 'tax_rule').prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( + subevent=subevent)), + )), + Prefetch('bundled_variation', + queryset=ItemVariation.objects.using( + settings.DATABASE_REPLICA + ).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( + subevent=subevent)), + )), + ) + ) + + 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( + *prefetch_membership_types, + Prefetch( + 'hidden_if_item_available', + queryset=event.items.annotate( + has_variations=Count('variations'), + ).prefetch_related( + prefetch_var, + prefetch_quotas, + prefetch_bundles, + ) + ), + prefetch_quotas, + prefetch_var, + prefetch_bundles, + ).annotate( + has_variations=Count('variations'), + subevent_disabled=Exists( + SubEventItem.objects.filter( + Q(disabled=True) + | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now())) + | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())), + item_id=OuterRef('pk'), + subevent=subevent, + ) + ), + mandatory_priced_addons=Exists( + ItemAddOn.objects.filter( + base_item_id=OuterRef('pk'), + min_count__gte=1, + price_included=False + ) + ), + requires_seat=requires_seat, + ).filter( + Exists(Quota.items.through.objects.filter(quota__subevent_id=subevent, item_id=OuterRef("pk"))), + subevent_disabled=False, + ).order_by('category__position', 'category_id', 'position', 'name') + if require_seat: + items = items.filter(requires_seat__gt=0) + elif require_seat is not None: + items = items.filter(requires_seat=0) + + if filter_items: + items = items.filter(pk__in=[a for a in filter_items if a.isdigit()]) + if filter_categories: + items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()]) + + display_add_to_cart = False + quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}' + quota_cache = quota_cache or event.cache.get(quota_cache_key) or {} + quota_cache_existed = bool(quota_cache) + + if subevent: + item_price_override = subevent.item_price_overrides + var_price_override = subevent.var_price_overrides + else: + item_price_override = {} + var_price_override = {} + + restrict_vars = set() + if voucher and voucher.quota_id: + # If a voucher is set to a specific quota, we need to filter out on that level + restrict_vars = set(voucher.quota.variations.all()) + + quotas_to_compute = [] + for item in items: + assert item.event_id == event.pk + item.event = event # save a database query if this is looked up + if item.has_variations: + for v in item.available_variations: + for q in v._subevent_quotas: + if q.pk not in quota_cache: + quotas_to_compute.append(q) + else: + for q in item._subevent_quotas: + if q.pk not in quota_cache: + quotas_to_compute.append(q) + + if quotas_to_compute: + qa = QuotaAvailability() + qa.queue(*quotas_to_compute) + qa.compute() + quota_cache.update({q.pk: r for q, r in qa.results.items()}) + + for item in items: + if voucher and voucher.item_id and voucher.variation_id: + # Restrict variations if the voucher only allows one + item.available_variations = [v for v in item.available_variations + if v.pk == voucher.variation_id] + + if channel.type_instance.unlimited_items_per_order: + max_per_order = sys.maxsize + else: + max_per_order = item.max_per_order or int(event.settings.max_items_per_order) + if voucher: + max_per_order = min(max_per_order, voucher.max_usages - voucher.redeemed) + + if item.hidden_if_available: + q = item.hidden_if_available.availability(_cache=quota_cache) + if q[0] == Quota.AVAILABILITY_OK: + item._remove = True + continue + + if item.hidden_if_item_available: + if item.hidden_if_item_available.has_variations: + item._dependency_available = any( + var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK + for var in item.hidden_if_item_available.available_variations + ) + else: + q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) + time_available = item.hidden_if_item_available.is_available() + item._dependency_available = (q[0] == Quota.AVAILABILITY_OK) and time_available + if item._dependency_available and item.hidden_if_item_available_mode == Item.UNAVAIL_MODE_HIDDEN: + item._remove = True + continue + + if item.require_membership and item.require_membership_hidden: + if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]): + item._remove = True + continue + + item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent) + + item.description = str(item.description) + for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent): + if resp: + item.description += ("
" if item.description else "") + resp + + if not item.has_variations: + item._remove = False + if not bool(item._subevent_quotas): + item._remove = True + continue + + if voucher and (voucher.allow_ignore_quota or voucher.block_quota): + item.cached_availability = ( + Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed + ) + else: + item.cached_availability = list( + item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) + ) + + if not ( + ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids + ) and event.settings.hide_sold_out and item.cached_availability[0] < Quota.AVAILABILITY_RESERVED: + item._remove = True + continue + + item.order_max = min( + item.cached_availability[1] + if item.cached_availability[1] is not None else sys.maxsize, + max_per_order + ) + + original_price = item_price_override.get(item.pk, item.default_price) + voucher_reduced = False + if voucher: + price = voucher.calculate_price(original_price) + voucher_reduced = price < original_price + include_bundled = not voucher.all_bundles_included + else: + price = original_price + include_bundled = True + + item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled) + if item.free_price and item.free_price_suggestion is not None and not voucher_reduced: + item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled) + else: + item.suggested_price = item.display_price + + if price != original_price: + item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True) + else: + item.original_price = ( + item.tax(item.original_price, currency=event.currency, include_bundled=True, + base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat + if item.original_price else None + ) + if not display_add_to_cart: + display_add_to_cart = not item.requires_seat and item.order_max > 0 + else: + for var in item.available_variations: + if var.require_membership and var.require_membership_hidden: + if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]): + var._remove = True + continue + + var.description = str(var.description) + for recv, resp in item_description.send(sender=event, item=item, variation=var, subevent=subevent): + if resp: + var.description += ("
" if var.description else "") + resp + + if voucher and (voucher.allow_ignore_quota or voucher.block_quota): + var.cached_availability = ( + Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed + ) + else: + var.cached_availability = list( + var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) + ) + + var.order_max = min( + var.cached_availability[1] + if var.cached_availability[1] is not None else sys.maxsize, + max_per_order + ) + + original_price = var_price_override.get(var.pk, var.price) + voucher_reduced = False + if voucher: + price = voucher.calculate_price(original_price) + voucher_reduced = price < original_price + include_bundled = not voucher.all_bundles_included + else: + price = original_price + include_bundled = True + + var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled) + + if item.free_price and var.free_price_suggestion is not None and not voucher_reduced: + var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency, + include_bundled=include_bundled) + elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced: + var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, + include_bundled=include_bundled) + else: + var.suggested_price = var.display_price + + if price != original_price: + var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True) + else: + var.original_price = ( + var.tax(var.original_price or item.original_price, currency=event.currency, + include_bundled=True, + base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat + ) if var.original_price or item.original_price else None + + var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent) + + item.original_price = ( + item.tax(item.original_price, currency=event.currency, include_bundled=True, + base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat + if item.original_price else None + ) + + item.available_variations = [ + v for v in item.available_variations if v._subevent_quotas and ( + not voucher or not voucher.quota_id or v in restrict_vars + ) and not getattr(v, '_remove', False) + ] + + if not (ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids) and event.settings.hide_sold_out: + item.available_variations = [v for v in item.available_variations + if v.cached_availability[0] >= Quota.AVAILABILITY_RESERVED] + + if voucher and voucher.variation_id: + item.available_variations = [v for v in item.available_variations + if v.pk == voucher.variation_id] + + if len(item.available_variations) > 0: + item.min_price = min([v.display_price.net if event.settings.display_net_prices else + v.display_price.gross for v in item.available_variations]) + item.max_price = max([v.display_price.net if event.settings.display_net_prices else + v.display_price.gross for v in item.available_variations]) + item.best_variation_availability = max([v.cached_availability[0] for v in item.available_variations]) + + item._remove = not bool(item.available_variations) + if not item._remove and not display_add_to_cart: + display_add_to_cart = not item.requires_seat and any(v.order_max > 0 for v in item.available_variations) + + if not quota_cache_existed and not voucher and not allow_addons and not base_qs_set and not filter_items and not filter_categories: + event.cache.set(quota_cache_key, quota_cache, 5) + items = [item for item in items + if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove] + return items, display_add_to_cart diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 99d027be0d..b2399352de 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -68,13 +68,11 @@ from pretix.base.timemachine import time_machine_now from pretix.base.views.tasks import AsyncAction from pretix.helpers.http import redirect_to_url from pretix.multidomain.urlreverse import eventreverse +from pretix.presale.utils import get_grouped_items, item_group_by_category from pretix.presale.views import ( CartMixin, EventViewMixin, allow_cors_if_namespaced, allow_frame_if_namespaced, get_cart, iframe_entry_view_wrapper, ) -from pretix.presale.views.event import ( - get_grouped_items, item_group_by_category, -) from pretix.presale.views.robots import NoSearchIndexViewMixin try: diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 355beed2fa..dcea17c844 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -34,7 +34,6 @@ import calendar import hashlib -import sys from collections import defaultdict from datetime import date, datetime, timedelta from decimal import Decimal @@ -47,10 +46,7 @@ from django import forms from django.conf import settings from django.contrib import messages from django.core.exceptions import PermissionDenied -from django.db.models import ( - Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value, -) -from django.db.models.lookups import Exact +from django.db.models import Count from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator @@ -65,15 +61,9 @@ from django.views.generic import TemplateView from pretix.base.auth import has_event_access_permission from pretix.base.forms.widgets import SplitDateTimePickerWidget -from pretix.base.models import ( - ItemVariation, Quota, SalesChannel, SeatCategoryMapping, Voucher, -) +from pretix.base.models import Quota, Voucher from pretix.base.models.event import Event, SubEvent -from pretix.base.models.items import ( - Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, -) from pretix.base.services.placeholders import PlaceholderContext -from pretix.base.services.quotas import QuotaAvailability from pretix.base.timemachine import time_machine_now from pretix.helpers.compat import date_fromisocalendar from pretix.helpers.formats.en.formats import ( @@ -82,7 +72,8 @@ from pretix.helpers.formats.en.formats import ( from pretix.helpers.http import redirect_to_url from pretix.multidomain.urlreverse import eventreverse from pretix.presale.ical import get_public_ical -from pretix.presale.signals import item_description, seatingframe_html_head +from pretix.presale.signals import seatingframe_html_head +from pretix.presale.utils import get_grouped_items, item_group_by_category from pretix.presale.views.organizer import ( EventListMixin, add_subevents_for_days, days_for_template, filter_qs_by_attr, filter_subevents_with_plugins, has_before_after, @@ -97,388 +88,6 @@ from . import ( SessionStore = import_module(settings.SESSION_ENGINE).SessionStore -def item_group_by_category(items): - return sorted( - [ - # a group is a tuple of a category and a list of items - (cat, [i for i in items if i.category == cat]) - for cat in set([i.category for i in items]) - # insert categories into a set for uniqueness - # a set is unsorted, so sort again by category - ], - key=lambda group: (group[0].position, group[0].id) if ( - group[0] is not None and group[0].id is not None) else (0, 0) - ) - - -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 - base_qs = base_qs if base_qs is not None else event.items - - requires_seat = Exists( - SeatCategoryMapping.objects.filter( - product_id=OuterRef('pk'), - subevent=subevent - ) - ) - if not event.settings.seating_choice: - requires_seat = Value(0, output_field=IntegerField()) - - variation_q = ( - Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) & - Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info')) - ) - if not voucher or not voucher.show_hidden_items: - variation_q &= Q(hide_without_voucher=False) - - if memberships is not None: - prefetch_membership_types = ['require_membership_types'] - else: - prefetch_membership_types = [] - - prefetch_var = Prefetch( - 'variations', - to_attr='available_variations', - queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate( - subevent_disabled=Exists( - SubEventItemVariation.objects.filter( - Q(disabled=True) - | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now())) - | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())), - variation_id=OuterRef('pk'), - subevent=subevent, - ) - ), - ).filter( - variation_q, - Q(all_sales_channels=True) | Q(limit_sales_channels=channel), - active=True, - quotas__isnull=False, - subevent_disabled=False - ).prefetch_related( - *prefetch_membership_types, - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( - subevent=subevent).select_related("subevent")) - ).distinct() - ) - prefetch_quotas = Prefetch( - 'quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent).select_related("subevent") - ) - prefetch_bundles = Prefetch( - 'bundles', - queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related( - Prefetch('bundled_item', - queryset=event.items.using(settings.DATABASE_REPLICA).select_related( - 'tax_rule').prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( - subevent=subevent)), - )), - Prefetch('bundled_variation', - queryset=ItemVariation.objects.using( - settings.DATABASE_REPLICA - ).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( - subevent=subevent)), - )), - ) - ) - - 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( - *prefetch_membership_types, - Prefetch( - 'hidden_if_item_available', - queryset=event.items.annotate( - has_variations=Count('variations'), - ).prefetch_related( - prefetch_var, - prefetch_quotas, - prefetch_bundles, - ) - ), - prefetch_quotas, - prefetch_var, - prefetch_bundles, - ).annotate( - has_variations=Count('variations'), - subevent_disabled=Exists( - SubEventItem.objects.filter( - Q(disabled=True) - | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now())) - | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())), - item_id=OuterRef('pk'), - subevent=subevent, - ) - ), - mandatory_priced_addons=Exists( - ItemAddOn.objects.filter( - base_item_id=OuterRef('pk'), - min_count__gte=1, - price_included=False - ) - ), - requires_seat=requires_seat, - ).filter( - Exists(Quota.items.through.objects.filter(quota__subevent_id=subevent, item_id=OuterRef("pk"))), - subevent_disabled=False, - ).order_by('category__position', 'category_id', 'position', 'name') - if require_seat: - items = items.filter(requires_seat__gt=0) - elif require_seat is not None: - items = items.filter(requires_seat=0) - - if filter_items: - items = items.filter(pk__in=[a for a in filter_items if a.isdigit()]) - if filter_categories: - items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()]) - - display_add_to_cart = False - quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}' - quota_cache = quota_cache or event.cache.get(quota_cache_key) or {} - quota_cache_existed = bool(quota_cache) - - if subevent: - item_price_override = subevent.item_price_overrides - var_price_override = subevent.var_price_overrides - else: - item_price_override = {} - var_price_override = {} - - restrict_vars = set() - if voucher and voucher.quota_id: - # If a voucher is set to a specific quota, we need to filter out on that level - restrict_vars = set(voucher.quota.variations.all()) - - quotas_to_compute = [] - for item in items: - assert item.event_id == event.pk - item.event = event # save a database query if this is looked up - if item.has_variations: - for v in item.available_variations: - for q in v._subevent_quotas: - if q.pk not in quota_cache: - quotas_to_compute.append(q) - else: - for q in item._subevent_quotas: - if q.pk not in quota_cache: - quotas_to_compute.append(q) - - if quotas_to_compute: - qa = QuotaAvailability() - qa.queue(*quotas_to_compute) - qa.compute() - quota_cache.update({q.pk: r for q, r in qa.results.items()}) - - for item in items: - if voucher and voucher.item_id and voucher.variation_id: - # Restrict variations if the voucher only allows one - item.available_variations = [v for v in item.available_variations - if v.pk == voucher.variation_id] - - if channel.type_instance.unlimited_items_per_order: - max_per_order = sys.maxsize - else: - max_per_order = item.max_per_order or int(event.settings.max_items_per_order) - if voucher: - max_per_order = min(max_per_order, voucher.max_usages - voucher.redeemed) - - if item.hidden_if_available: - q = item.hidden_if_available.availability(_cache=quota_cache) - if q[0] == Quota.AVAILABILITY_OK: - item._remove = True - continue - - if item.hidden_if_item_available: - if item.hidden_if_item_available.has_variations: - item._dependency_available = any( - var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK - for var in item.hidden_if_item_available.available_variations - ) - else: - q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) - time_available = item.hidden_if_item_available.is_available() - item._dependency_available = (q[0] == Quota.AVAILABILITY_OK) and time_available - if item._dependency_available and item.hidden_if_item_available_mode == Item.UNAVAIL_MODE_HIDDEN: - item._remove = True - continue - - if item.require_membership and item.require_membership_hidden: - if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]): - item._remove = True - continue - - item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent) - - item.description = str(item.description) - for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent): - if resp: - item.description += ("
" if item.description else "") + resp - - if not item.has_variations: - item._remove = False - if not bool(item._subevent_quotas): - item._remove = True - continue - - if voucher and (voucher.allow_ignore_quota or voucher.block_quota): - item.cached_availability = ( - Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed - ) - else: - item.cached_availability = list( - item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) - ) - - if not ( - ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids - ) and event.settings.hide_sold_out and item.cached_availability[0] < Quota.AVAILABILITY_RESERVED: - item._remove = True - continue - - item.order_max = min( - item.cached_availability[1] - if item.cached_availability[1] is not None else sys.maxsize, - max_per_order - ) - - original_price = item_price_override.get(item.pk, item.default_price) - voucher_reduced = False - if voucher: - price = voucher.calculate_price(original_price) - voucher_reduced = price < original_price - include_bundled = not voucher.all_bundles_included - else: - price = original_price - include_bundled = True - - item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled) - if item.free_price and item.free_price_suggestion is not None and not voucher_reduced: - item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled) - else: - item.suggested_price = item.display_price - - if price != original_price: - item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True) - else: - item.original_price = ( - item.tax(item.original_price, currency=event.currency, include_bundled=True, - base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat - if item.original_price else None - ) - if not display_add_to_cart: - display_add_to_cart = not item.requires_seat and item.order_max > 0 - else: - for var in item.available_variations: - if var.require_membership and var.require_membership_hidden: - if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]): - var._remove = True - continue - - var.description = str(var.description) - for recv, resp in item_description.send(sender=event, item=item, variation=var, subevent=subevent): - if resp: - var.description += ("
" if var.description else "") + resp - - if voucher and (voucher.allow_ignore_quota or voucher.block_quota): - var.cached_availability = ( - Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed - ) - else: - var.cached_availability = list( - var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) - ) - - var.order_max = min( - var.cached_availability[1] - if var.cached_availability[1] is not None else sys.maxsize, - max_per_order - ) - - original_price = var_price_override.get(var.pk, var.price) - voucher_reduced = False - if voucher: - price = voucher.calculate_price(original_price) - voucher_reduced = price < original_price - include_bundled = not voucher.all_bundles_included - else: - price = original_price - include_bundled = True - - var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled) - - if item.free_price and var.free_price_suggestion is not None and not voucher_reduced: - var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency, - include_bundled=include_bundled) - elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced: - var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, - include_bundled=include_bundled) - else: - var.suggested_price = var.display_price - - if price != original_price: - var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True) - else: - var.original_price = ( - var.tax(var.original_price or item.original_price, currency=event.currency, - include_bundled=True, - base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat - ) if var.original_price or item.original_price else None - - var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent) - - item.original_price = ( - item.tax(item.original_price, currency=event.currency, include_bundled=True, - base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat - if item.original_price else None - ) - - item.available_variations = [ - v for v in item.available_variations if v._subevent_quotas and ( - not voucher or not voucher.quota_id or v in restrict_vars - ) and not getattr(v, '_remove', False) - ] - - if not (ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids) and event.settings.hide_sold_out: - item.available_variations = [v for v in item.available_variations - if v.cached_availability[0] >= Quota.AVAILABILITY_RESERVED] - - if voucher and voucher.variation_id: - item.available_variations = [v for v in item.available_variations - if v.pk == voucher.variation_id] - - if len(item.available_variations) > 0: - item.min_price = min([v.display_price.net if event.settings.display_net_prices else - v.display_price.gross for v in item.available_variations]) - item.max_price = max([v.display_price.net if event.settings.display_net_prices else - v.display_price.gross for v in item.available_variations]) - item.best_variation_availability = max([v.cached_availability[0] for v in item.available_variations]) - - item._remove = not bool(item.available_variations) - if not item._remove and not display_add_to_cart: - display_add_to_cart = not item.requires_seat and any(v.order_max > 0 for v in item.available_variations) - - if not quota_cache_existed and not voucher and not allow_addons and not base_qs_set and not filter_items and not filter_categories: - event.cache.set(quota_cache_key, quota_cache, 5) - items = [item for item in items - if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove] - return items, display_add_to_cart - - @method_decorator(allow_frame_if_namespaced, 'dispatch') @method_decorator(iframe_entry_view_wrapper, 'dispatch') class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 4fdff85710..97be246648 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -93,10 +93,10 @@ from pretix.multidomain.urlreverse import eventreverse, eventreverse_absolute from pretix.presale.forms.checkout import InvoiceAddressForm, QuestionsForm from pretix.presale.forms.order import OrderPositionChangeForm from pretix.presale.signals import question_form_fields_overrides +from pretix.presale.utils import get_grouped_items from pretix.presale.views import ( CartMixin, EventViewMixin, iframe_entry_view_wrapper, ) -from pretix.presale.views.event import get_grouped_items from pretix.presale.views.robots import NoSearchIndexViewMixin logger = logging.getLogger(__name__) diff --git a/src/pretix/presale/views/waiting.py b/src/pretix/presale/views/waiting.py index e135e3f338..dcc79ad13a 100644 --- a/src/pretix/presale/views/waiting.py +++ b/src/pretix/presale/views/waiting.py @@ -40,8 +40,8 @@ from pretix.presale.views import EventViewMixin, iframe_entry_view_wrapper from ...base.i18n import get_language_without_region from ...base.models import Voucher, WaitingListEntry from ..forms.waitinglist import WaitingListForm +from ..utils import get_grouped_items from . import allow_frame_if_namespaced -from .event import get_grouped_items @method_decorator(allow_frame_if_namespaced, 'dispatch') diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 325e2b5853..8574a6d261 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -68,10 +68,8 @@ from pretix.helpers.thumb import get_thumbnail from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.presale.forms.organizer import meta_filtersets from pretix.presale.style import get_theme_vars_css +from pretix.presale.utils import get_grouped_items, item_group_by_category from pretix.presale.views.cart import get_or_create_cart_id -from pretix.presale.views.event import ( - get_grouped_items, item_group_by_category, -) from pretix.presale.views.organizer import ( EventListMixin, add_events_for_days, add_subevents_for_days, days_for_template, filter_qs_by_attr, filter_subevents_with_plugins,