From 7cff4321e0ee04dec7b482b84945eb78ab2e3d31 Mon Sep 17 00:00:00 2001 From: luelista Date: Thu, 16 Jul 2026 12:52:08 +0200 Subject: [PATCH] Order change: Fix crash if addon has hidden_if_item_available (Z#23236380) (#6357) * Fix crash in rebooking if addon has hidden_if_item_available * Move get_grouped_items into its own file and rename to prepare_item_list_for_shop (but keep a reference under original name for plugin compatibility) * Make Item[Variation].unavailability_reason helper functions next to get_grouped_items * Use current_unavailability_reason attribute instead of unavailability_reason() * Add test case for: Order has product with addon 1; Addon 1 is hidden if Addon 2 is available; When order is changed, Addon 1 should not be removable * Fix special case if addon product is now set to require a voucher (vouchers for addons are not supported) --------- Co-authored-by: Kara Engelhardt --- src/pretix/base/models/items.py | 36 -- src/pretix/base/services/cross_selling.py | 4 +- src/pretix/base/services/orders.py | 65 +-- src/pretix/presale/checkoutflow.py | 4 +- src/pretix/presale/productlist.py | 460 ++++++++++++++++++++++ src/pretix/presale/views/cart.py | 8 +- src/pretix/presale/views/event.py | 410 +------------------ src/pretix/presale/views/order.py | 30 +- src/pretix/presale/views/waiting.py | 4 +- src/pretix/presale/views/widget.py | 8 +- src/tests/presale/test_order_change.py | 44 ++- 11 files changed, 572 insertions(+), 501 deletions(-) create mode 100644 src/pretix/presale/productlist.py diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 79f1229d1..272a26397 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -885,26 +885,6 @@ class Item(LoggedModel): return False return True - def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: - now_dt = now_dt or time_machine_now() - subevent_item = subevent and subevent.item_overrides.get(self.pk) - if not self.active: - return 'active' - elif self.available_from and self.available_from > now_dt: - return 'available_from' - elif self.available_until and self.available_until < now_dt: - return 'available_until' - elif (self.require_voucher or self.hide_without_voucher) and not has_voucher: - return 'require_voucher' - elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt: - return 'available_from' - elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt: - return 'available_until' - elif self.hidden_if_item_available and self._dependency_available: - return 'hidden_if_item_available' - else: - return None - def _get_quotas(self, ignored_quotas=None, subevent=None): check_quotas = set(getattr( self, '_subevent_quotas', # Utilize cache in product list @@ -1413,22 +1393,6 @@ class ItemVariation(models.Model): return False return True - def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: - now_dt = now_dt or time_machine_now() - subevent_var = subevent and subevent.var_overrides.get(self.pk) - if not self.active: - return 'active' - elif self.available_from and self.available_from > now_dt: - return 'available_from' - elif self.available_until and self.available_until < now_dt: - return 'available_until' - elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt: - return 'available_from' - elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt: - return 'available_until' - else: - return None - @property def meta_data(self): data = self.item.meta_data diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py index f407d2fad..316674715 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.productlist import prepare_item_list_for_shop class DummyCategory: @@ -162,7 +162,7 @@ class CrossSellingService: ] def _prepare_items(self, subevent, items_qs, discount_info): - items, _btn = get_grouped_items( + items, _btn = prepare_item_list_for_shop( self.event, subevent=subevent, voucher=None, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index c26226e97..9d8298bfc 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.productlist import prepare_item_list_for_shop from pretix.testutils.middleware import debugflags_var @@ -1947,13 +1948,18 @@ class OrderChangeManager: :param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values), ``"count"``, and ``"price"``. - :param limit_main_positions: By default, the method works on all methods of the order. If you set this to a + :param limit_main_positions: By default, the method works on all positions of the order. If you set this to a queryset or a list of positions, all other positions and their add-ons will be kept untouched. """ if self._operations: raise ValueError("Setting addons should be the first/only operation") + def _allowed_on_order_sales_channel(item_or_var, order): + return item_or_var.all_sales_channels or ( + order.sales_channel.identifier in (s.identifier for s in item_or_var.limit_sales_channels.all()) + ) + # Prepare containers for min/max check of products item_counts = Counter() for p in self.order.positions.all(): @@ -2047,13 +2053,11 @@ class OrderChangeManager: if not item.is_available() or (variation and not variation.is_available()): raise OrderError(error_messages['unavailable']) - if not item.all_sales_channels: - if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()): - raise OrderError(error_messages['unavailable']) + if not _allowed_on_order_sales_channel(item, self.order): + raise OrderError(error_messages['unavailable']) - if variation and not variation.all_sales_channels: - if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()): - raise OrderError(error_messages['unavailable']) + if variation and not _allowed_on_order_sales_channel(variation, self.order): + raise OrderError(error_messages['unavailable']) if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(): raise OrderError(error_messages['not_for_sale']) @@ -2101,6 +2105,36 @@ class OrderChangeManager: ) item_counts[item] += 1 + def _addon_is_available(a): + # 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. + # We always set voucher=None because that's what's done when generating the form in + # OrderChangeMixin (vouchers for addons are not supported). + # This also prevents accidental removal through the UI because a hidden product will no longer + # be part of the input. + if not _allowed_on_order_sales_channel(a.item, self.order) or ( + a.variation and not _allowed_on_order_sales_channel(a.variation, self.order) + ): + return False + + items, _ = prepare_item_list_for_shop( + self.order.event, + channel=self.order.sales_channel, + subevent=a.subevent, + voucher=None, + base_qs=Item.objects.filter(pk=a.item.pk), + allow_addons=True + ) + if (not items) or items[0].current_unavailability_reason: + return False + + if a.variation: + variations = [var for var in items[0].available_variations if var.pk == a.variation.pk] + if (not variations) or variations[0].current_unavailability_reason: + return False + + return True + # Detect removed add-ons and create RemoveOperations for cp, al in list(current_addons.items()): for k, v in al.items(): @@ -2110,22 +2144,7 @@ class OrderChangeManager: for a in current_addons[cp][k][:current_num - input_num]: if a.canceled: continue - 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. - # We always pass has_voucher=True because if a product now requires a voucher, it usually does - # not mean it should be unremovable for others. - # This also prevents accidental removal through the UI because a hidden product will no longer - # 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 a.item.all_sales_channels and - not a.item.limit_sales_channels.contains(self.order.sales_channel) - ) - ) - if is_unavailable: + if not _addon_is_available(a): # "Re-select" add-on selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1 continue diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 4256b07cd..b6db5dcd9 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -87,6 +87,7 @@ from pretix.presale.forms.checkout import ( ContactForm, InvoiceAddressForm, InvoiceNameForm, MembershipForm, ) from pretix.presale.forms.customer import AuthenticationForm, RegistrationForm +from pretix.presale.productlist import prepare_item_list_for_shop from pretix.presale.signals import ( checkout_all_optional, checkout_confirm_messages, checkout_flow_steps, contact_form_fields, contact_form_fields_overrides, @@ -99,7 +100,6 @@ 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 @@ -603,7 +603,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): if ckey not in item_cache: # Get all items to possibly show - items, _btn = get_grouped_items( + items, _btn = prepare_item_list_for_shop( self.request.event, subevent=cartpos.subevent, voucher=None, diff --git a/src/pretix/presale/productlist.py b/src/pretix/presale/productlist.py new file mode 100644 index 000000000..86fad53f8 --- /dev/null +++ b/src/pretix/presale/productlist.py @@ -0,0 +1,460 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix 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 sys +from datetime import datetime +from typing import Optional + +from django.conf import settings +from django.db.models import ( + Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value, +) +from django.db.models.lookups import Exact + +from pretix.base.models import ( + ItemVariation, 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 +from pretix.presale.signals import item_description + + +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 prepare_item_list_for_shop(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), + Exists(Quota.variations.through.objects.filter(quota__subevent_id=subevent, itemvariation_id=OuterRef("pk"))), + active=True, + 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 = _get_item_unavailability_reason(item, 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 = _get_variant_unavailability_reason(var, 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 + + +def _get_item_unavailability_reason(item, now_dt: Optional[datetime]=None, has_voucher=False, subevent=None) -> Optional[str]: + now_dt = now_dt or time_machine_now() + subevent_item = subevent and subevent.item_overrides.get(item.pk) + if not item.active: + return 'active' + elif item.available_from and item.available_from > now_dt: + return 'available_from' + elif item.available_until and item.available_until < now_dt: + return 'available_until' + elif (item.require_voucher or item.hide_without_voucher) and not has_voucher: + return 'require_voucher' + elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt: + return 'available_from' + elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt: + return 'available_until' + elif item.hidden_if_item_available and item._dependency_available: + return 'hidden_if_item_available' + else: + return None + + +def _get_variant_unavailability_reason(variant, now_dt: Optional[datetime]=None, has_voucher=False, subevent=None) -> Optional[str]: + now_dt = now_dt or time_machine_now() + subevent_var = subevent and subevent.var_overrides.get(variant.pk) + if not variant.active: + return 'active' + elif variant.available_from and variant.available_from > now_dt: + return 'available_from' + elif variant.available_until and variant.available_until < now_dt: + return 'available_until' + elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt: + return 'available_from' + elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt: + return 'available_until' + else: + return None diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 99d027be0..ab84b8401 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -68,13 +68,13 @@ 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.productlist import ( + item_group_by_category, prepare_item_list_for_shop, +) 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: @@ -669,7 +669,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView context['max_times'] = self.voucher.max_usages - self.voucher.redeemed # Fetch all items - items, display_add_to_cart = get_grouped_items( + items, display_add_to_cart = prepare_item_list_for_shop( self.request.event, subevent=self.subevent, voucher=self.voucher, diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 6c3fc7d23..4d48902d7 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,10 @@ 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.productlist import ( + item_group_by_category, prepare_item_list_for_shop, +) +from pretix.presale.signals import seatingframe_html_head 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, @@ -94,391 +87,12 @@ from . import ( iframe_entry_view_wrapper, ) +from pretix.presale.productlist import prepare_item_list_for_shop as get_grouped_items # noqa + + 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), - Exists(Quota.variations.through.objects.filter(quota__subevent_id=subevent, itemvariation_id=OuterRef("pk"))), - active=True, - 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): @@ -582,7 +196,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): if not self.request.event.has_subevents or self.subevent: # Fetch all items - items, display_add_to_cart = get_grouped_items( + items, display_add_to_cart = prepare_item_list_for_shop( self.request.event, subevent=self.subevent, filter_items=self.request.GET.getlist('item'), @@ -634,10 +248,6 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): context['has_addon_choices'] = any(cp.has_addon_choices for cp in get_cart(self.request)) templating_context = PlaceholderContext(event_or_subevent=self.subevent or self.request.event, event=self.request.event) - - for field in ('presale_has_ended_text',): - context[field] = templating_context.format(str(self.request.event.settings[field])) - if self.subevent: context['frontpage_text'] = templating_context.format(str(self.subevent.frontpage_text)) else: diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 4fdff8571..c13164463 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -79,7 +79,7 @@ from pretix.base.services.invoices import ( ) from pretix.base.services.orders import ( OrderChangeManager, OrderError, _try_auto_refund, cancel_order, - change_payment_provider, error_messages, + change_payment_provider, ) from pretix.base.services.pricing import get_price from pretix.base.services.tickets import generate, invalidate_cache @@ -92,11 +92,11 @@ from pretix.helpers.safedownload import check_token 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.productlist import prepare_item_list_for_shop from pretix.presale.signals import question_form_fields_overrides 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__) @@ -1452,7 +1452,7 @@ class OrderChangeMixin: if ckey not in item_cache: # Get all items to possibly show - items, _btn = get_grouped_items( + items, _btn = prepare_item_list_for_shop( self.request.event, subevent=p.subevent, voucher=None, @@ -1592,30 +1592,6 @@ class OrderChangeMixin: if val: selected[i, None] = val, price - if sum(a[0] for a in selected.values()) > category['max_count']: - raise ValidationError( - error_messages['addon_max_count'] % { - 'base': str(form['pos'].item.name), - 'max': category['max_count'], - 'cat': str(category['category'].name), - } - ) - elif sum(a[0] for a in selected.values()) < category['min_count']: - raise ValidationError( - error_messages['addon_min_count'] % { - 'base': str(form['pos'].item.name), - 'min': category['min_count'], - 'cat': str(category['category'].name), - } - ) - elif any(sum(v[0] for k, v in selected.items() if k[0] == i) > 1 for i in category['items']) and not category['multi_allowed']: - raise ValidationError( - error_messages['addon_no_multi'] % { - 'base': str(form['pos'].item.name), - 'cat': str(category['category'].name), - } - ) - return selected def post(self, request, *args, **kwargs): diff --git a/src/pretix/presale/views/waiting.py b/src/pretix/presale/views/waiting.py index e135e3f33..f94fdbc9a 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 ..productlist import prepare_item_list_for_shop from . import allow_frame_if_namespaced -from .event import get_grouped_items @method_decorator(allow_frame_if_namespaced, 'dispatch') @@ -53,7 +53,7 @@ class WaitingView(EventViewMixin, FormView): @cached_property def itemvars(self): customer = getattr(self.request, 'customer', None) - items, display_add_to_cart = get_grouped_items( + items, display_add_to_cart = prepare_item_list_for_shop( self.request.event, subevent=self.subevent, require_seat=None, diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 90f441963..a51af50a4 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -67,11 +67,11 @@ from pretix.helpers.daterange import daterange 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.productlist import ( + item_group_by_category, prepare_item_list_for_shop, +) from pretix.presale.style import get_theme_vars_css 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, @@ -321,7 +321,7 @@ class WidgetAPIProductList(EventListMixin, View): ).values_list('item_id', flat=True) ) - items, display_add_to_cart = get_grouped_items( + items, display_add_to_cart = prepare_item_list_for_shop( self.request.event, subevent=self.subevent, voucher=self.voucher, diff --git a/src/tests/presale/test_order_change.py b/src/tests/presale/test_order_change.py index 24c110c38..b53d6ce59 100644 --- a/src/tests/presale/test_order_change.py +++ b/src/tests/presale/test_order_change.py @@ -994,7 +994,7 @@ class OrderChangeAddonsTest(BaseOrdersTest): self.order.refresh_from_db() assert self.order.total == Decimal('23.00') - def test_do_not_remove_unavailable_on_adding(self): + def test_do_not_remove_timeunavailable_on_adding(self): self.iao.max_count = 2 self.iao.save() self.workshop1.available_until = now() - datetime.timedelta(days=1) @@ -1036,6 +1036,48 @@ class OrderChangeAddonsTest(BaseOrdersTest): with scopes_disabled(): assert self.ticket_pos.addons.count() == 2 + def test_do_not_remove_dependencyunavailable_on_adding(self): + self.iao.max_count = 2 + self.iao.save() + self.workshop1.hidden_if_item_available = self.workshop2 + self.workshop1.save() + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert '
  • 1x Workshop 1
  • ' in response.content.decode() + assert f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}' not in response.content.decode() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '1' + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), form_data, follow=True + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + assert self.ticket_pos.addons.count() == 2 + def test_do_not_overbook_unavailable_on_adding(self): self.iao.max_count = 1 self.iao.save()