diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 1fe05491d5..8984622b58 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -97,6 +97,10 @@ class CartError(Exception): super().__init__(msg) +class CartPositionError(CartError): + pass + + error_messages = { 'busy': gettext_lazy( 'We were not able to process your request completely as the ' @@ -106,6 +110,9 @@ error_messages = { 'unknown_position': gettext_lazy('Unknown cart position.'), 'subevent_required': pgettext_lazy('subevent', 'No date was specified.'), 'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'), + 'positions_removed': gettext_lazy( + 'Some products can no longer be purchased and have been removed from your cart for the following reason: %s' + ), 'unavailable': gettext_lazy( 'Some of the products you selected are no longer available. ' 'Please see below for details.' @@ -258,6 +265,138 @@ def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_ return vouchers_ok, _voucher_depend_on_cart +def _check_position_constraints( + event: Event, item: Item, variation: ItemVariation, voucher: Voucher, subevent: SubEvent, + seat: Seat, sales_channel: SalesChannel, already_in_cart: bool, cart_is_expired: bool, real_now_dt: datetime, + item_requires_seat: bool, is_addon: bool, is_bundled: bool, +): + """ + Checks if a cart position with the given constraints can still be sold. This checks configuration and time-based + constraints of item, subevent, and voucher. + + It does NOT + - check if quota/voucher/seat are still available + - check prices + - check memberships + - perform any checks that go beyond the single line (like item.max_per_order) + """ + time_machine_now_dt = time_machine_now(real_now_dt) + # Item or variation disabled + # Item disabled or unavailable by time + if not item.is_available(time_machine_now_dt) or (variation and not variation.is_available(time_machine_now_dt)): + raise CartPositionError(error_messages['unavailable']) + + # Invalid media policy for online sale + if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + mt = MEDIA_TYPES[item.media_type] + if not mt.medium_created_by_server: + raise CartPositionError(error_messages['media_usage_not_implemented']) + elif item.media_policy == Item.MEDIA_POLICY_REUSE: + raise CartPositionError(error_messages['media_usage_not_implemented']) + + # Item removed from sales channel + if not item.all_sales_channels: + if sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()): + raise CartPositionError(error_messages['unavailable']) + + # Variation removed from sales channel + if variation and not variation.all_sales_channels: + if sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()): + raise CartPositionError(error_messages['unavailable']) + + # Item disabled or unavailable by time in subevent + if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(time_machine_now_dt): + raise CartPositionError(error_messages['not_for_sale']) + + # Variation disabled or unavailable by time in subevent + if subevent and variation and variation.pk in subevent.var_overrides and \ + not subevent.var_overrides[variation.pk].is_available(time_machine_now_dt): + raise CartPositionError(error_messages['not_for_sale']) + + # Item requires a variation (should never happen) + if item.has_variations and not variation: + raise CartPositionError(error_messages['not_for_sale']) + + # Variation belongs to wrong item (should never happen) + if variation and variation.item_id != item.pk: + raise CartPositionError(error_messages['not_for_sale']) + + # Voucher does not apply to product + if voucher and not voucher.applies_to(item, variation): + raise CartPositionError(error_messages['voucher_invalid_item']) + + # Voucher does not apply to seat + if voucher and voucher.seat and voucher.seat != seat: + raise CartPositionError(error_messages['voucher_invalid_seat']) + + # Voucher does not apply to subevent + if voucher and voucher.subevent_id and voucher.subevent_id != subevent.pk: + raise CartPositionError(error_messages['voucher_invalid_subevent']) + + # Voucher expired + if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt: + raise CartPositionError(error_messages['voucher_expired']) + + # Subevent has been disabled + if subevent and not subevent.active: + raise CartPositionError(error_messages['inactive_subevent']) + + # Subevent sale not started + if subevent and subevent.effective_presale_start and time_machine_now_dt < subevent.effective_presale_start: + raise CartPositionError(error_messages['not_started']) + + # Subevent sale has ended + if subevent and subevent.presale_has_ended: + raise CartPositionError(error_messages['ended']) + + # Payment for subevent no longer possible + if subevent: + tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) + if tlv: + term_last = make_aware(datetime.combine( + tlv.datetime(subevent).date(), + time(hour=23, minute=59, second=59) + ), event.timezone) + if term_last < time_machine_now_dt: + raise CartPositionError(error_messages['payment_ended']) + + # Seat required but no seat given + if item_requires_seat and not seat: + raise CartPositionError(error_messages['seat_invalid']) + + # Seat given but no seat required + if seat and not item_requires_seat: + raise CartPositionError(error_messages['seat_forbidden']) + + # Item requires to be add-on but is top-level position + if item.category and item.category.is_addon and not is_addon: + raise CartPositionError(error_messages['addon_only']) + + # Item requires bundling but is top-level position + if item.require_bundling and not is_bundled: + raise CartPositionError(error_messages['bundled_only']) + + # Seat for wrong product + if seat and seat.product != item: + raise CartPositionError(error_messages['seat_invalid']) + + # Seat blocked + if seat and seat.blocked and sales_channel.identifier not in event.settings.seating_allow_blocked_seats_for_channel: + raise CartPositionError(error_messages['seat_invalid']) + + # Item requires voucher but no voucher given + if item.require_voucher and voucher is None and not is_bundled: + raise CartPositionError(error_messages['voucher_required']) + + # Item or variation is hidden without voucher but no voucher is given + if ( + (item.hide_without_voucher or (variation and variation.hide_without_voucher)) and + (voucher is None or not voucher.show_hidden_items) and + not is_bundled + ): + raise CartPositionError(error_messages['voucher_required']) + + class CartManager: AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas', 'addon_to', 'subevent', 'bundled', 'seat', 'listed_price', @@ -294,6 +433,7 @@ class CartManager: self._widget_data = widget_data or {} self._sales_channel = sales_channel self.num_extended_positions = 0 + self.price_change_for_extended = False if reservation_time: self._reservation_time = reservation_time @@ -421,14 +561,14 @@ class CartManager: if cartsize > limit: raise CartError(error_messages['max_items'] % limit) - def _check_item_constraints(self, op, current_ops=[]): + def _check_item_constraints(self, op): if isinstance(op, (self.AddOperation, self.ExtendOperation)): if not ( (isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or (isinstance(op, self.ExtendOperation) and op.position.is_bundled) ): if op.item.require_voucher and op.voucher is None: - if getattr(op, 'voucher_ignored', False): + if getattr(op, 'voucher_ignored', False): # todo?? raise CartError(error_messages['voucher_redeemed']) raise CartError(error_messages['voucher_required']) @@ -440,88 +580,39 @@ class CartManager: raise CartError(error_messages['voucher_redeemed']) raise CartError(error_messages['voucher_required']) - if not op.item.is_available() or (op.variation and not op.variation.is_available()): - raise CartError(error_messages['unavailable']) - - if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): - mt = MEDIA_TYPES[op.item.media_type] - if not mt.medium_created_by_server: - raise CartError(error_messages['media_usage_not_implemented']) - elif op.item.media_policy == Item.MEDIA_POLICY_REUSE: - raise CartError(error_messages['media_usage_not_implemented']) - - if not op.item.all_sales_channels: - if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()): - raise CartError(error_messages['unavailable']) - - if op.variation and not op.variation.all_sales_channels: - if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()): - raise CartError(error_messages['unavailable']) - - if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available(): - raise CartError(error_messages['not_for_sale']) - - if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and \ - not op.subevent.var_overrides[op.variation.pk].is_available(): - raise CartError(error_messages['not_for_sale']) - - if op.item.has_variations and not op.variation: - raise CartError(error_messages['not_for_sale']) - - if op.variation and op.variation.item_id != op.item.pk: - raise CartError(error_messages['not_for_sale']) - - if op.voucher and not op.voucher.applies_to(op.item, op.variation): - raise CartError(error_messages['voucher_invalid_item']) - - if op.voucher and op.voucher.seat and op.voucher.seat != op.seat: - raise CartError(error_messages['voucher_invalid_seat']) - - if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk: - raise CartError(error_messages['voucher_invalid_subevent']) - - if op.subevent and not op.subevent.active: - raise CartError(error_messages['inactive_subevent']) - - if op.subevent and op.subevent.presale_start and time_machine_now(self.real_now_dt) < op.subevent.presale_start: - raise CartError(error_messages['not_started']) - - if op.subevent and op.subevent.presale_has_ended: - raise CartError(error_messages['ended']) - - seated = self._is_seated(op.item, op.subevent) - if ( - seated and ( - not op.seat or ( - op.seat.blocked and - self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel - ) - ) - ): - raise CartError(error_messages['seat_invalid']) - elif op.seat and not seated: - raise CartError(error_messages['seat_forbidden']) - elif op.seat and op.seat.product != op.item: - raise CartError(error_messages['seat_invalid']) - elif op.seat and op.count > 1: + if op.seat and op.count > 1: raise CartError('Invalid request: A seat can only be bought once.') - if op.subevent: - tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if tlv: - term_last = make_aware(datetime.combine( - tlv.datetime(op.subevent).date(), - time(hour=23, minute=59, second=59) - ), self.event.timezone) - if term_last < time_machine_now(self.real_now_dt): - raise CartError(error_messages['payment_ended']) + if isinstance(op, self.AddOperation): + is_addon = op.addon_to + is_bundled = op.addon_to == "FAKE" + else: + is_addon = op.position.addon_to + is_bundled = op.position.is_bundled - if isinstance(op, self.AddOperation): - if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'): - raise CartError(error_messages['addon_only']) - - if op.item.require_bundling and not op.addon_to == 'FAKE': - raise CartError(error_messages['bundled_only']) + try: + _check_position_constraints( + event=self.event, + item=op.item, + variation=op.variation, + voucher=op.voucher, + subevent=op.subevent, + seat=op.seat, + sales_channel=self._sales_channel, + already_in_cart=isinstance(op, self.ExtendOperation), + cart_is_expired=isinstance(op, self.ExtendOperation), + real_now_dt=self.real_now_dt, + item_requires_seat=self._is_seated(op.item, op.subevent), + is_addon=is_addon, + is_bundled=is_bundled, + ) + # Quota, seat, and voucher availability is checked for in perform_operations + # Price changes are checked for in extend_expired_positions + except CartPositionError as e: + if e.args[0] == error_messages['voucher_required'] and getattr(op, 'voucher_ignored', False): + # This is the case where someone clicks +1 on a voucher-only item with a fully redeemed voucher: + raise CartPositionError(error_messages['voucher_redeemed']) + raise def _get_price(self, item: Item, variation: Optional[ItemVariation], voucher: Optional[Voucher], custom_price: Optional[Decimal], @@ -604,7 +695,11 @@ class CartManager: quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price, price_after_voucher=price_after_voucher, ) - self._check_item_constraints(op) + try: + self._check_item_constraints(op) + except CartPositionError as e: + self._operations.append(self.RemoveOperation(position=cp)) + err = error_messages['positions_removed'] % str(e) if cp.voucher: self._voucher_use_diff[cp.voucher] += 2 @@ -797,7 +892,7 @@ class CartManager: custom_price_input_is_net=False, voucher_ignored=False, ) - self._check_item_constraints(bop, operations) + self._check_item_constraints(bop) bundled.append(bop) listed_price = get_listed_price(item, variation, subevent) @@ -836,7 +931,7 @@ class CartManager: custom_price_input_is_net=self.event.settings.display_net_prices, voucher_ignored=voucher_ignored, ) - self._check_item_constraints(op, operations) + self._check_item_constraints(op) operations.append(op) self._quota_diff.update(quota_diff) @@ -975,7 +1070,7 @@ class CartManager: custom_price_input_is_net=self.event.settings.display_net_prices, voucher_ignored=False, ) - self._check_item_constraints(op, operations) + self._check_item_constraints(op) operations.append(op) # Check constraints on the add-on combinations @@ -1172,7 +1267,9 @@ class CartManager: op.position.delete() elif isinstance(op, (self.AddOperation, self.ExtendOperation)): - # Create a CartPosition for as much items as we can + if isinstance(op, self.ExtendOperation) and (op.position.pk in deleted_positions or not op.position.pk): + continue # Already deleted in other operation + # Create a CartPosition for as many items as we can requested_count = quota_available_count = voucher_available_count = op.count if op.seat: @@ -1343,6 +1440,8 @@ class CartManager: addons.delete() op.position.delete() elif available_count == 1: + if op.price_after_voucher != op.position.price_after_voucher: + self.price_change_for_extended = True op.position.expires = self._expiry op.position.max_extend = self._max_expiry_extend op.position.listed_price = op.listed_price @@ -1444,6 +1543,14 @@ class CartManager: return diff + def _remove_parents_if_bundles_are_removed(self): + removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)} + for op in self._operations: + if isinstance(op, self.RemoveOperation): + if op.position.is_bundled and op.position.addon_to_id not in removed_positions: + self._operations.append(self.RemoveOperation(position=op.position.addon_to)) + removed_positions.add(op.position.addon_to_id) + def commit(self): self._check_presale_dates() self._check_max_cart_size() @@ -1453,6 +1560,7 @@ class CartManager: err = err or self._check_min_per_voucher() self._extend_expiry_of_valid_existing_positions() + self._remove_parents_if_bundles_are_removed() err = self._perform_operations() or err self.recompute_final_prices_and_taxes() @@ -1708,7 +1816,12 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm.commit() - return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend} + return { + "success": cm.num_extended_positions, + "expiry": cm._expiry, + "max_expiry_extend": cm._max_expiry_extend, + "price_changed": cm.price_change_for_extended, + } except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e8be424cf5..e8b4bf9411 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -81,7 +81,7 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.payment import GiftCardPayment, PaymentException from pretix.base.reldate import RelativeDateWrapper from pretix.base.secrets import assign_ticket_secret -from pretix.base.services import tickets +from pretix.base.services import cart, tickets from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, invoice_transmission_separately, order_invoice_transmission_separately, @@ -130,6 +130,9 @@ class OrderError(Exception): error_messages = { + 'positions_removed': gettext_lazy( + 'Some products can no longer be purchased and have been removed from your cart for the following reason: %s' + ), 'unavailable': gettext_lazy( 'Some of the products you selected were no longer available. ' 'Please see below for details.' @@ -182,14 +185,6 @@ error_messages = { 'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.' ), 'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'), - 'some_subevent_not_started': gettext_lazy( - 'The booking period for one of the events in your cart has not yet started. The ' - 'affected positions have been removed from your cart.' - ), - 'some_subevent_ended': gettext_lazy( - 'The booking period for one of the events in your cart has ended. The affected ' - 'positions have been removed from your cart.' - ), 'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'), 'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'), 'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'), @@ -744,12 +739,37 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti deleted_positions.add(cp.pk) cp.delete() - sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk)) + sorted_positions = list(sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))) for cp in sorted_positions: cp._cached_quotas = list(cp.quotas) + for cp in sorted_positions: + try: + cart._check_position_constraints( + event=event, + item=cp.item, + variation=cp.variation, + voucher=cp.voucher, + subevent=cp.subevent, + seat=cp.seat, + sales_channel=sales_channel, + already_in_cart=True, + cart_is_expired=cp.expires < now_dt, + real_now_dt=now_dt, + item_requires_seat=cp.requires_seat, + is_addon=bool(cp.addon_to_id), + is_bundled=bool(cp.addon_to_id) and cp.is_bundled, + ) + # Quota, seat, and voucher availability is checked for below + # Prices are checked for below + # Memberships are checked in _create_order + except cart.CartPositionError as e: + err = error_messages['positions_removed'] % str(e) + delete(cp) + # Create locks + sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted if any(cp.expires < now() + timedelta(seconds=LOCK_TRUST_WINDOW) for cp in sorted_positions): # No need to perform any locking if the cart positions still guarantee everything long enough. full_lock_required = any( @@ -774,15 +794,12 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti # Check availability for i, cp in enumerate(sorted_positions): - if cp.pk in deleted_positions: + if cp.pk in deleted_positions or not cp.pk: continue - if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()): - err = err or error_messages['unavailable'] - delete(cp) - continue quotas = cp._cached_quotas + # Product per order limits products_seen[cp.item] += 1 if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order: err = error_messages['max_items_per_product'] % { @@ -792,6 +809,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) break + # Voucher availability if cp.voucher: v_usages[cp.voucher] += 1 if cp.voucher not in v_avail: @@ -806,48 +824,14 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) continue - if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start: - err = err or error_messages['some_subevent_not_started'] - delete(cp) - break - - if cp.subevent: - tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if tlv: - term_last = make_aware(datetime.combine( - tlv.datetime(cp.subevent).date(), - time(hour=23, minute=59, second=59) - ), event.timezone) - if term_last < time_machine_now_dt: - err = err or error_messages['some_subevent_ended'] - delete(cp) - break - - if cp.subevent and cp.subevent.presale_has_ended: - err = err or error_messages['some_subevent_ended'] - delete(cp) - break - - if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen: + # Check duplicate seats in order + if cp.seat in seats_seen: err = err or error_messages['seat_invalid'] delete(cp) break + if cp.seat: seats_seen.add(cp.seat) - - if cp.item.require_voucher and cp.voucher is None and not cp.is_bundled: - delete(cp) - err = err or error_messages['voucher_required'] - break - - if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and ( - cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation) - ) and not cp.is_bundled: - delete(cp) - err = error_messages['voucher_required'] - break - - if cp.seat: # Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every # time, since we absolutely can not overbook a seat. if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier): @@ -855,34 +839,13 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) continue - if cp.expires >= now_dt and not cp.voucher: - # Other checks are not necessary - continue - + # Check useful quota configuration if len(quotas) == 0: err = err or error_messages['unavailable'] delete(cp) continue - if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(time_machine_now_dt): - err = err or error_messages['unavailable'] - delete(cp) - continue - - if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \ - not cp.subevent.var_overrides[cp.variation.pk].is_available(time_machine_now_dt): - err = err or error_messages['unavailable'] - delete(cp) - continue - - if cp.voucher: - if cp.voucher.valid_until and cp.voucher.valid_until < time_machine_now_dt: - err = err or error_messages['voucher_expired'] - delete(cp) - continue - quota_ok = True - ignore_all_quotas = cp.expires >= now_dt or ( cp.voucher and ( cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None) @@ -914,7 +877,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti }) # Check prices - sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] + sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted old_total = sum(cp.price for cp in sorted_positions) for i, cp in enumerate(sorted_positions): if cp.listed_price is None: @@ -945,7 +908,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) continue - sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] + sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted discount_results = apply_discounts( event, sales_channel.identifier, diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index b9cfebd41a..892719f67e 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -435,7 +435,7 @@ def cart_session(request): @method_decorator(allow_frame_if_namespaced, 'dispatch') class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View): task = apply_voucher - known_errortypes = ['CartError'] + known_errortypes = ['CartError', 'CartPositionError'] def get_success_message(self, value): return _('We applied the voucher to as many products in your cart as we could.') @@ -513,7 +513,7 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View): @method_decorator(allow_frame_if_namespaced, 'dispatch') class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View): task = remove_cart_position - known_errortypes = ['CartError'] + known_errortypes = ['CartError', 'CartPositionError'] def get_success_message(self, value): if CartPosition.objects.filter(cart_id=get_or_create_cart_id(self.request)).exists(): @@ -542,7 +542,7 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View): @method_decorator(allow_frame_if_namespaced, 'dispatch') class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View): task = clear_cart - known_errortypes = ['CartError'] + known_errortypes = ['CartError', 'CartPositionError'] def get_success_message(self, value): create_empty_cart_id(self.request) @@ -556,7 +556,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View): @method_decorator(allow_frame_if_namespaced, 'dispatch') class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View): task = extend_cart_reservation - known_errortypes = ['CartError'] + known_errortypes = ['CartError', 'CartPositionError'] def _ajax_response_data(self, value): if isinstance(value, dict): @@ -566,7 +566,11 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View): def get_success_message(self, value): if value['success'] > 0: - return _('Your cart timeout was extended.') + if value.get('price_changed'): + return _('Your cart timeout was extended. Please note that some of the prices in your cart ' + 'changed.') + else: + return _('Your cart timeout was extended.') def post(self, request, *args, **kwargs): return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(), @@ -578,7 +582,7 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View): @method_decorator(iframe_entry_view_wrapper, 'dispatch') class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): task = add_items_to_cart - known_errortypes = ['CartError'] + known_errortypes = ['CartError', 'CartPositionError'] def get_success_message(self, value): return _('The products have been successfully added to your cart.') diff --git a/src/tests/base/test_memberships.py b/src/tests/base/test_memberships.py index 1b074ccec0..33ce29e684 100644 --- a/src/tests/base/test_memberships.py +++ b/src/tests/base/test_memberships.py @@ -745,6 +745,8 @@ def test_use_membership(event, customer, membership, requiring_ticket): item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123", used_membership=membership ) + q = event.quotas.create(size=None, name="foo") + q.items.add(requiring_ticket) order = _create_order(event, email='dummy@example.org', positions=[cp1], now_dt=now(), sales_channel=event.organizer.sales_channels.get(identifier="web"), @@ -767,6 +769,8 @@ def test_use_membership_invalid(event, customer, membership, requiring_ticket): membership.date_start -= timedelta(days=100) membership.date_end -= timedelta(days=100) membership.save() + q = event.quotas.create(size=None, name="foo") + q.items.add(requiring_ticket) cp1 = CartPosition.objects.create( item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123", used_membership=membership diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index b424227c9c..d02de01908 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -1428,6 +1428,27 @@ class CartTest(CartTestMixin, TestCase): self.assertEqual(cp2.expires, now() + self.cart_reservation_time) self.assertEqual(cp2.max_extend, now() + 11 * self.cart_reservation_time) + def test_expired_cart_extend_price_change_note(self): + start_time = datetime.datetime(2024, 1, 1, 10, 00, 00, tzinfo=datetime.timezone.utc) + max_extend = start_time + 11 * self.cart_reservation_time + with scopes_disabled(): + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=max_extend, max_extend=max_extend + ) + cp1.update_listed_price_and_voucher() + self.ticket.default_price = Decimal("25.00") + self.ticket.save() + with freezegun.freeze_time(max_extend + timedelta(hours=1)): + response = self.client.post('/%s/%s/cart/extend' % (self.orga.slug, self.event.slug), { + }, follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertIn('some of the prices in your cart changed', doc.select('.alert-success')[0].text) + with scopes_disabled(): + cp1.refresh_from_db() + self.assertEqual(cp1.price, Decimal("25.00")) + self.assertEqual(cp1.expires, now() + self.cart_reservation_time) + def test_expired_cart_extend_fails_partially_on_bundled(self): start_time = datetime.datetime(2024, 1, 1, 10, 00, 00, tzinfo=datetime.timezone.utc) max_extend = start_time + 11 * self.cart_reservation_time @@ -3408,6 +3429,22 @@ class CartAddonTest(CartTestMixin, TestCase): assert cp2.expires > now() assert cp2.addon_to_id == cp1.pk + @classscope(attr='orga') + def test_expand_expired_price_change(self): + cp1 = CartPosition.objects.create( + expires=now() - timedelta(minutes=10), max_extend=now() + 10 * self.cart_reservation_time, + item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.ticket.default_price = Decimal("25.00") + self.ticket.save() + self.cm.extend_expired_positions() + self.cm.commit() + cp1.refresh_from_db() + assert cp1.expires > now() + assert cp1.listed_price == Decimal("25.00") + assert cp1.price == Decimal("25.00") + @classscope(attr='orga') def test_expand_expired_refresh_voucher(self): v = Voucher.objects.create(item=self.ticket, value=Decimal('20.00'), event=self.event, price_mode='set', @@ -4080,6 +4117,8 @@ class CartBundleTest(CartTestMixin, TestCase): @classscope(attr='orga') def test_extend_bundled_and_addon(self): + self.trans.require_bundling = False + self.trans.save() cp = CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, price=21.5, expires=now() - timedelta(minutes=10), max_extend=now() + 10 * self.cart_reservation_time diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 22d7b4c6d8..f8a6ebd758 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -2669,7 +2669,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) q = se.quotas.create(name="foo", size=None, event=self.event) q.items.add(self.ticket) cr1 = CartPosition.objects.create( @@ -2839,8 +2839,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): with scopes_disabled(): self.event.has_subevents = True self.event.save() - se = self.event.subevents.create(name='Foo', date_from=now()) - se2 = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) self.quota_tickets.size = 0 self.quota_tickets.subevent = se2 self.quota_tickets.save() @@ -2880,7 +2880,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) q = se.quotas.create(name="foo", size=None, event=self.event) q.items.add(self.ticket) SubEventItem.objects.create(subevent=se, item=self.ticket, price=24) @@ -2901,7 +2901,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) q = se.quotas.create(name="foo", size=None, event=self.event) q.items.add(self.ticket) SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, disabled=True) @@ -2919,7 +2919,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) q = se.quotas.create(name="foo", size=None, event=self.event) q.items.add(self.workshop2) q.variations.add(self.workshop2b) @@ -2938,7 +2938,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) q = se.quotas.create(name="foo", size=None, event=self.event) q.items.add(self.ticket) SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, available_until=now() - timedelta(days=1)) @@ -2956,7 +2956,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) q = se.quotas.create(name="foo", size=None, event=self.event) q.items.add(self.workshop2) q.variations.add(self.workshop2b) @@ -3735,8 +3735,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now()) - se2 = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) self.quota_tickets.size = 10 self.quota_tickets.subevent = se2 self.quota_tickets.save() @@ -4165,7 +4165,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): with scopes_disabled(): self.event.has_subevents = True self.event.save() - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) self.workshopquota.size = 1 self.workshopquota.subevent = se self.workshopquota.save() @@ -4214,7 +4214,10 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.settings.display_net_prices = True self.event.save() - se = self.event.subevents.create(name='Foo', date_from=now(), presale_start=now() + datetime.timedelta(days=1)) + se = self.event.subevents.create(name='Foo', date_from=now(), presale_start=now() + datetime.timedelta(days=1), + active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, price=23, expires=now() + timedelta(minutes=10), subevent=se @@ -4224,7 +4227,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) doc = BeautifulSoup(response.content.decode(), "lxml") self.assertGreaterEqual(len(doc.select(".alert-danger")), 1) - assert 'booking period for one of the events in your cart has not yet started.' in response.content.decode() + assert 'booking period for this event has not yet started.' in response.content.decode() with scopes_disabled(): assert not CartPosition.objects.filter(cart_id=self.session_key).exists() @@ -4233,7 +4236,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.has_subevents = True self.event.settings.display_net_prices = True self.event.save() - se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() - datetime.timedelta(days=1)) + se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() - datetime.timedelta(days=1), active=True) CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, price=23, expires=now() + timedelta(minutes=10), subevent=se @@ -4243,7 +4246,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) doc = BeautifulSoup(response.content.decode(), "lxml") self.assertGreaterEqual(len(doc.select(".alert-danger")), 1) - assert 'booking period for one of the events in your cart has ended.' in response.content.decode() + assert 'The booking period for this event has ended.' in response.content.decode() with scopes_disabled(): assert not CartPosition.objects.filter(cart_id=self.session_key).exists() @@ -4253,7 +4256,9 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.settings.display_net_prices = True self.event.save() self.event.settings.payment_term_last = 'RELDATE/1/23:59:59/date_from/' - se = self.event.subevents.create(name='Foo', date_from=now()) + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, price=23, expires=now() + timedelta(minutes=10), subevent=se @@ -4263,7 +4268,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) doc = BeautifulSoup(response.content.decode(), "lxml") self.assertGreaterEqual(len(doc.select(".alert-danger")), 1) - assert 'booking period for one of the events in your cart has ended.' in response.content.decode() + assert 'All payments for this event need to be confirmed already, so no new orders can be created.' in response.content.decode() with scopes_disabled(): assert not CartPosition.objects.filter(cart_id=self.session_key).exists() @@ -4272,7 +4277,10 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.date_to = now() - datetime.timedelta(days=1) self.event.save() with scopes_disabled(): - se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() + datetime.timedelta(days=1)) + se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() + datetime.timedelta(days=1), + active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, price=23, expires=now() + timedelta(minutes=10), subevent=se @@ -4283,6 +4291,25 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): doc = BeautifulSoup(response.content.decode(), "lxml") self.assertEqual(len(doc.select(".thank-you")), 1) + def test_confirm_subevent_disabled(self): + with scopes_disabled(): + self.event.has_subevents = True + self.event.settings.display_net_prices = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=False) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10), subevent=se + ) + + self._set_payment() + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertGreaterEqual(len(doc.select(".alert-danger")), 1) + assert 'selected event date is not active.' in response.content.decode() + with scopes_disabled(): + assert not CartPosition.objects.filter(cart_id=self.session_key).exists() + def test_before_presale_timemachine(self): self._login_with_permission(self.orga) self._enable_test_mode() @@ -4497,6 +4524,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.assertEqual(Order.objects.first().locale, 'de') def test_variation_require_approval(self): + self.workshop2.category = None + self.workshop2.save() self.workshop2a.require_approval = True self.workshop2a.save() with scopes_disabled(): @@ -4518,6 +4547,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): def test_item_with_variations_require_approval(self): self.workshop2.require_approval = True + self.workshop2.category = None self.workshop2.save() with scopes_disabled(): cr1 = CartPosition.objects.create(