# # 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 # . # # This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of # the Apache License 2.0 can be obtained at . # # This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A # full history of changes and contributors is available at . # # This file contains Apache-licensed contributions copyrighted by: Ben Hagan, Tobias Kunze # # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. import re import uuid from collections import Counter, defaultdict, namedtuple from datetime import datetime, time, timedelta from decimal import Decimal from time import sleep from typing import List, Optional from celery.exceptions import MaxRetriesExceededError from django import forms from django.conf import settings from django.core.exceptions import ValidationError from django.db import DatabaseError, transaction from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value from django.db.models.aggregates import Min from django.dispatch import receiver from django.utils.timezone import make_aware, now from django.utils.translation import ( gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy, ) from django_scopes import scopes_disabled from pretix.base.i18n import language from pretix.base.media import MEDIA_TYPES from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel, Seat, SeatCategoryMapping, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import OrderFee from pretix.base.models.tax import TaxRule from pretix.base.reldate import RelativeDateWrapper from pretix.base.services.checkin import _save_answers from pretix.base.services.locking import LockTimeoutException, lock_objects from pretix.base.services.pricing import ( apply_discounts, apply_rounding, get_line_price, get_listed_price, get_price, is_included_for_free, ) from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.tasks import ProfiledEventTask from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList from pretix.base.signals import validate_cart_addons from pretix.base.templatetags.rich_text import rich_text from pretix.base.timemachine import time_machine_now, time_machine_now_assigned from pretix.celery_app import app from pretix.presale.signals import ( checkout_confirm_messages, fee_calculation_for_cart, ) from pretix.testutils.middleware import debugflags_var class CartError(Exception): def __init__(self, *args): msg = args[0] msgargs = args[1] if len(args) > 1 else None self.args = args if msgargs: msg = _(msg) % msgargs else: # force msg to string to make sure lazy-translation is done in current locale-context # otherwise translation might happen in celery-context, which uses default-locale # also translate with _/gettext to keep it backwards compatible msg = _(str(msg)) super().__init__(msg) error_messages = { 'busy': gettext_lazy( 'We were not able to process your request completely as the ' 'server was too busy. Please try again.' ), 'empty': gettext_lazy('You did not select any products.'), '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.'), 'unavailable': gettext_lazy( 'Some of the products you selected are no longer available. ' 'Please see below for details.' ), 'in_part': gettext_lazy( 'Some of the products you selected are no longer available in ' 'the quantity you selected. Please see below for details.' ), 'unavailable_listed': gettext_lazy( 'Some of the products you selected are no longer available. ' 'The following products are affected and have not been added to your cart: %s' ), 'in_part_listed': gettext_lazy( 'Some of the products you selected are no longer available in ' 'the quantity you selected. The following products are affected and have not ' 'been added to your cart: %s' ), 'max_items': ngettext_lazy( "You cannot select more than %s item per order.", "You cannot select more than %s items per order." ), 'max_items_per_product': ngettext_lazy( "You cannot select more than %(max)s item of the product %(product)s.", "You cannot select more than %(max)s items of the product %(product)s.", "max" ), 'min_items_per_product': ngettext_lazy( "You need to select at least %(min)s item of the product %(product)s.", "You need to select at least %(min)s items of the product %(product)s.", "min" ), 'min_items_per_product_removed': ngettext_lazy( "We removed %(product)s from your cart as you can not buy less than %(min)s item of it.", "We removed %(product)s from your cart as you can not buy less than %(min)s items of it.", "min" ), 'not_started': gettext_lazy('The booking period for this event has not yet started.'), 'ended': gettext_lazy('The booking period for this event has ended.'), 'payment_ended': gettext_lazy('All payments for this event need to be confirmed already, so no new orders can be created.'), 'some_subevent_not_started': gettext_lazy( 'The booking period for this event 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.'), 'price_not_a_number': gettext_lazy('The entered price is not a number.'), 'price_too_high': gettext_lazy('The entered price is to high.'), 'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'), 'voucher_min_usages': ngettext_lazy( 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.', 'number' ), 'voucher_min_usages_removed': ngettext_lazy( 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. ' 'We have therefore removed some positions from your cart that can no longer be purchased like this.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. ' 'We have therefore removed some positions from your cart that can no longer be purchased like this.', 'number' ), 'voucher_redeemed': gettext_lazy('This voucher code has already been used the maximum number of times allowed.'), 'voucher_redeemed_cart': gettext_lazy( 'This voucher code is currently locked since it is already contained in a cart. This ' 'might mean that someone else is redeeming this voucher right now, or that you tried ' 'to redeem it before but did not complete the checkout process. You can try to use it ' 'again in %d minutes.' ), 'voucher_redeemed_partial': gettext_lazy('This voucher code can only be redeemed %d more times.'), 'voucher_whole_cart_not_combined': gettext_lazy('Applying a voucher to the whole cart should not be combined with other operations.'), 'voucher_double': gettext_lazy( 'You already used this voucher code. Remove the associated line from your ' 'cart if you want to use it for a different product.' ), 'voucher_expired': gettext_lazy('This voucher is expired.'), 'voucher_invalid_item': gettext_lazy('This voucher is not valid for this product.'), 'voucher_invalid_seat': gettext_lazy('This voucher is not valid for this seat.'), 'voucher_no_match': gettext_lazy( 'We did not find any position in your cart that we could use this voucher for. If you want ' 'to add something new to your cart using that voucher, you can do so with the voucher ' 'redemption option on the bottom of the page.' ), 'voucher_item_not_available': gettext_lazy( 'Your voucher is valid for a product that is currently not for sale.'), 'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'), 'voucher_required': gettext_lazy('You need a valid voucher code to order this product.'), 'inactive_subevent': pgettext_lazy('subevent', 'The selected event date is not active.'), 'addon_invalid_base': gettext_lazy('You can not select an add-on for the selected product.'), 'addon_duplicate_item': gettext_lazy('You can not select two variations of the same add-on product.'), 'addon_max_count': ngettext_lazy( 'You can select at most %(max)s add-on from the category %(cat)s for the product %(base)s.', 'You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.', 'max' ), 'addon_min_count': ngettext_lazy( 'You need to select at least %(min)s add-on from the category %(cat)s for the product %(base)s.', 'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.', 'min' ), 'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'), 'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'), 'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'), 'seat_required': gettext_lazy('You need to select a specific seat.'), 'seat_invalid': gettext_lazy('Please select a valid seat.'), 'seat_forbidden': gettext_lazy('You can not select a seat for this position.'), 'seat_unavailable': gettext_lazy('The seat you selected has already been taken. Please select a different seat.'), 'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'), 'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."), 'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'), 'media_usage_not_implemented': gettext_lazy('The configuration of this product requires mapping to a physical ' 'medium, which is currently not available online.'), } def _get_quota_availability(quota_diff, now_dt): quotas_ok = defaultdict(int) qa = QuotaAvailability() qa.queue(*[k for k, v in quota_diff.items() if v > 0]) qa.compute(now_dt=now_dt) for quota, count in quota_diff.items(): if count <= 0: quotas_ok[quota] = 0 break avail = qa.results[quota] if avail[1] is not None and avail[1] < count: quotas_ok[quota] = min(count, avail[1]) else: quotas_ok[quota] = count return quotas_ok def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_ids): vouchers_ok = {} _voucher_depend_on_cart = set() for voucher, count in voucher_use_diff.items(): voucher.refresh_from_db() if voucher.valid_until is not None and voucher.valid_until < now_dt: raise CartError(error_messages['voucher_expired']) redeemed_in_carts = CartPosition.objects.filter( Q(voucher=voucher) & Q(event=event) & Q(expires__gte=now_dt) ).exclude(pk__in=exclude_position_ids) cart_count = redeemed_in_carts.count() v_avail = voucher.max_usages - voucher.redeemed - cart_count if cart_count > 0: _voucher_depend_on_cart.add(voucher) vouchers_ok[voucher] = v_avail return vouchers_ok, _voucher_depend_on_cart class CartManager: AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas', 'addon_to', 'subevent', 'bundled', 'seat', 'listed_price', 'price_after_voucher', 'custom_price_input', 'custom_price_input_is_net', 'voucher_ignored')) RemoveOperation = namedtuple('RemoveOperation', ('position',)) VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price_after_voucher')) ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'voucher', 'quotas', 'subevent', 'seat', 'listed_price', 'price_after_voucher')) order = { RemoveOperation: 10, VoucherOperation: 15, ExtendOperation: 20, AddOperation: 30 } def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel, invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None): """ Creates a new CartManager for an event. """ self.event = event self.cart_id = cart_id self.real_now_dt = now() self._operations = [] self._quota_diff = Counter() self._voucher_use_diff = Counter() self._items_cache = {} self._subevents_cache = {} self._variations_cache = {} self._seated_cache = {} self.invoice_address = invoice_address self._widget_data = widget_data or {} self._sales_channel = sales_channel self.num_extended_positions = 0 if reservation_time: self._reservation_time = reservation_time else: self._reservation_time = timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) self._expiry = self.real_now_dt + self._reservation_time self._max_expiry_extend = self.real_now_dt + (self._reservation_time * 11) @property def positions(self): return self.event.cartposition_set.filter( Q(cart_id=self.cart_id) ).select_related('item', 'subevent') def _is_seated(self, item, subevent): if not self.event.settings.seating_choice: return False if (item, subevent) not in self._seated_cache: self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists() return self._seated_cache[item, subevent] def _check_presale_dates(self): if self.event.presale_start and time_machine_now(self.real_now_dt) < self.event.presale_start: raise CartError(error_messages['not_started']) if self.event.presale_has_ended: raise CartError(error_messages['ended']) if not self.event.has_subevents: tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) if tlv: term_last = make_aware(datetime.combine( tlv.datetime(self.event).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']) def _extend_expiry_of_valid_existing_positions(self): # real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small # delta to reduce risk of extending already expired CartPositions. padded_now_dt = self.real_now_dt + timedelta(seconds=5) # Make sure we do not extend past the max_extend timestamp, allowing users to extend their valid positions up # to 11 times the reservation time. If we add new positions to the cart while valid positions exist, the new # positions' reservation will also be limited to max_extend of the oldest position. # Only after all positions expire, an ExtendOperation may reset max_extend to another 11x reservation_time. max_extend_existing = self.positions.filter(expires__gt=padded_now_dt).aggregate(m=Min('max_extend'))['m'] if max_extend_existing: self._expiry = min(self._expiry, max_extend_existing) self._max_expiry_extend = max_extend_existing # Extend this user's cart session to ensure all items in the cart expire at the same time # We can extend the reservation of items which are not yet expired without risk if self._expiry > padded_now_dt: self.num_extended_positions += self.positions.filter( expires__gt=padded_now_dt, expires__lt=self._expiry, ).update( expires=self._expiry, ) def _delete_out_of_timeframe(self): err = None for cp in self.positions: if not cp.pk: continue if cp.subevent and cp.subevent.presale_start and time_machine_now(self.real_now_dt) < cp.subevent.presale_start: err = error_messages['some_subevent_not_started'] cp.addons.all().delete() cp.delete() continue if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end: err = error_messages['some_subevent_ended'] cp.addons.all().delete() cp.delete() continue if cp.subevent: tlv = self.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) ), self.event.timezone) if term_last < time_machine_now(self.real_now_dt): err = error_messages['some_subevent_ended'] cp.addons.all().delete() cp.delete() continue return err def _update_subevents_cache(self, se_ids: List[int]): self._subevents_cache.update({ i.pk: i for i in self.event.subevents.filter(id__in=[i for i in se_ids if i and i not in self._subevents_cache]) }) def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]): self._items_cache.update({ i.pk: i for i in self.event.items.select_related('category').prefetch_related( 'addons', 'bundles', 'addons__addon_category', 'quotas' ).annotate( has_variations=Count('variations'), ).filter( id__in=[i for i in item_ids if i and i not in self._items_cache] ).order_by() }) self._variations_cache.update({ v.pk: v for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related( 'quotas' ).select_related('item', 'item__event').filter( id__in=[i for i in variation_ids if i and i not in self._variations_cache] ).order_by() }) def _check_max_cart_size(self): if not self._sales_channel.type_instance.unlimited_items_per_order: cartsize = self.positions.filter(addon_to__isnull=True).count() cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to]) cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if not op.position.addon_to_id]) limit = min(int(self.event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE) if cartsize > limit: raise CartError(error_messages['max_items'] % limit) def _check_item_constraints(self, op, current_ops=[]): 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): raise CartError(error_messages['voucher_redeemed']) raise CartError(error_messages['voucher_required']) if ( (op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher)) and (op.voucher is None or not op.voucher.show_hidden_items) ): if getattr(op, 'voucher_ignored', False): 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: 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): 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']) def _get_price(self, item: Item, variation: Optional[ItemVariation], voucher: Optional[Voucher], custom_price: Optional[Decimal], subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False, bundled_sum=Decimal('0.00')): try: return get_price( item, variation, voucher, custom_price, subevent, custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices, invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum ) except TaxRule.SaleNotAllowed: raise CartError(error_messages['country_blocked']) except ValueError as e: if str(e) == 'price_too_high': raise CartError(error_messages['price_too_high']) else: raise e def extend_expired_positions(self): requires_seat = Exists( SeatCategoryMapping.objects.filter( Q(product=OuterRef('item')) & (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True)) ) ) if not self.event.settings.seating_choice: requires_seat = Value(0, output_field=IntegerField()) expired = self.positions.filter(expires__lte=self.real_now_dt).select_related( 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item' ).annotate( requires_seat=requires_seat ).prefetch_related( 'item__quotas', 'variation__quotas', 'addons' ).order_by('-is_bundled') err = None for cp in expired: removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)} if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions): continue cp.item.requires_seat = self.event.settings.seating_choice and cp.requires_seat if cp.is_bundled: bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first() if bundle: if cp.addon_to.voucher_id and cp.addon_to.voucher.all_bundles_included: listed_price = Decimal('0.00') else: listed_price = bundle.designated_price else: listed_price = cp.price price_after_voucher = listed_price else: if cp.addon_to_id and is_included_for_free(cp.item, cp.addon_to): listed_price = Decimal('0.00') else: listed_price = get_listed_price(cp.item, cp.variation, cp.subevent) if cp.voucher: price_after_voucher = cp.voucher.calculate_price(listed_price) else: price_after_voucher = listed_price quotas = list(cp.quotas) if not quotas: self._operations.append(self.RemoveOperation(position=cp)) err = error_messages['unavailable'] continue if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota): for quota in quotas: self._quota_diff[quota] += 1 else: quotas = [] op = self.ExtendOperation( position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1, quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price, price_after_voucher=price_after_voucher, ) self._check_item_constraints(op) if cp.voucher: self._voucher_use_diff[cp.voucher] += 2 self._operations.append(op) return err def apply_voucher(self, voucher_code: str): if self._operations: raise CartError(error_messages['voucher_whole_cart_not_combined']) try: voucher = self.event.vouchers.get(code__iexact=voucher_code.strip()) except Voucher.DoesNotExist: if self.event.organizer.accepted_gift_cards.filter(secret__iexact=voucher_code).exists(): raise CartError(error_messages['gift_card']) else: raise CartError(error_messages['voucher_invalid']) voucher_use_diff = Counter() ops = [] if not voucher.is_active(): raise CartError(error_messages['voucher_expired']) for p in self.positions: if p.voucher_id: continue if not voucher.applies_to(p.item, p.variation): continue if voucher.seat and voucher.seat != p.seat: continue if voucher.subevent_id and voucher.subevent_id != p.subevent_id: continue if p.is_bundled: continue if p.custom_price_input and p.custom_price_input != p.listed_price: continue if p.listed_price is None: if p.addon_to_id and is_included_for_free(p.item, p.addon_to): listed_price = Decimal('0.00') else: listed_price = get_listed_price(p.item, p.variation, p.subevent) else: listed_price = p.listed_price price_after_voucher = voucher.calculate_price(listed_price) voucher_use_diff[voucher] += 1 ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher))) for voucher, cnt in list(voucher_use_diff.items()): if 0 < cnt < voucher.min_usages_remaining: raise CartError( error_messages['voucher_min_usages'] % { 'voucher': voucher.code, 'number': voucher.min_usages_remaining, } ) # If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits # the user the most. ops.sort(key=lambda k: k[0], reverse=True) self._operations += [k[1] for k in ops] if not voucher_use_diff: raise CartError(error_messages['voucher_no_match']) self._voucher_use_diff += voucher_use_diff def add_new_items(self, items: List[dict]): # Fetch items from the database self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items]) self._update_subevents_cache([i['subevent'] for i in items if i.get('subevent')]) quota_diff = Counter() voucher_use_diff = Counter() operations = [] for i in items: if self.event.has_subevents: if not i.get('subevent') or int(i.get('subevent')) not in self._subevents_cache: raise CartError(error_messages['subevent_required']) subevent = self._subevents_cache[int(i.get('subevent'))] else: subevent = None # When a seat is given, we ignore the item that was given, since we can infer it from the # seat. The variation is still relevant, though! seat = None if i.get('seat'): try: seat = (subevent or self.event).seats.get(seat_guid=i.get('seat')) except Seat.DoesNotExist: raise CartError(error_messages['seat_invalid']) except Seat.MultipleObjectsReturned: raise CartError(error_messages['seat_invalid']) i['item'] = seat.product_id if i['item'] not in self._items_cache: self._update_items_cache([i['item']], [i['variation']]) # Check whether the specified items are part of what we just fetched from the database # If they are not, the user supplied item IDs which either do not exist or belong to # a different event if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache): raise CartError(error_messages['not_for_sale']) item = self._items_cache[i['item']] variation = self._variations_cache[i['variation']] if i['variation'] is not None else None voucher = None voucher_ignored = False if i.get('voucher'): try: voucher = self.event.vouchers.get(code__iexact=i.get('voucher').strip()) except Voucher.DoesNotExist: raise CartError(error_messages['voucher_invalid']) else: voucher_use_diff[voucher] += i['count'] if i.get('voucher_ignore_if_redeemed', False): # This is a special case handling for when a user clicks "+" on an existing line in their cart # that has a voucher attached. If the voucher still has redemptions left, we'll add another line # with the same voucher, but if it does not we silently continue as if there was no voucher, # leading to either a higher-priced ticket or an error. Still, this leads to less error cases # than either of the possible default assumptions. predicted_redeemed_after = ( voucher.redeemed + CartPosition.objects.filter(voucher=voucher, expires__gte=self.real_now_dt).count() + self._voucher_use_diff[voucher] + voucher_use_diff[voucher] ) if predicted_redeemed_after > voucher.max_usages: i.pop('voucher') voucher_ignored = True voucher = None voucher_use_diff[voucher] -= i['count'] # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. quotas = list(item.quotas.filter(subevent=subevent) if variation is None else variation.quotas.filter(subevent=subevent)) if not quotas: raise CartError(error_messages['unavailable']) if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota): for quota in quotas: quota_diff[quota] += i['count'] else: quotas = [] # Fetch bundled items bundled = [] db_bundles = list(item.bundles.all()) self._update_items_cache([b.bundled_item_id for b in db_bundles], [b.bundled_variation_id for b in db_bundles]) for bundle in db_bundles: if bundle.bundled_item_id not in self._items_cache or ( bundle.bundled_variation_id and bundle.bundled_variation_id not in self._variations_cache ): raise CartError(error_messages['not_for_sale']) bitem = self._items_cache[bundle.bundled_item_id] bvar = self._variations_cache[bundle.bundled_variation_id] if bundle.bundled_variation_id else None bundle_quotas = list(bitem.quotas.filter(subevent=subevent) if bvar is None else bvar.quotas.filter(subevent=subevent)) if not bundle_quotas: raise CartError(error_messages['unavailable']) if not voucher or not voucher.allow_ignore_quota: for quota in bundle_quotas: quota_diff[quota] += bundle.count * i['count'] else: bundle_quotas = [] if voucher and voucher.all_bundles_included: bundled_price = Decimal('0.00') else: bundled_price = bundle.designated_price bop = self.AddOperation( count=bundle.count, item=bitem, variation=bvar, voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent, bundled=[], seat=None, listed_price=bundled_price, price_after_voucher=bundled_price, custom_price_input=None, custom_price_input_is_net=False, voucher_ignored=False, ) self._check_item_constraints(bop, operations) bundled.append(bop) listed_price = get_listed_price(item, variation, subevent) if voucher: price_after_voucher = voucher.calculate_price(listed_price) else: price_after_voucher = listed_price custom_price = None if item.free_price and i.get('price'): custom_price = re.sub('[^0-9.,]', '', str(i.get('price'))) if not custom_price: raise CartError(error_messages['price_not_a_number']) try: custom_price = forms.DecimalField(localize=True).to_python(custom_price) except: try: custom_price = Decimal(custom_price) except: raise CartError(error_messages['price_not_a_number']) if custom_price > 99_999_999_999: raise CartError(error_messages['price_too_high']) op = self.AddOperation( count=i['count'], item=item, variation=variation, voucher=voucher, quotas=quotas, addon_to=False, subevent=subevent, bundled=bundled, seat=seat, listed_price=listed_price, price_after_voucher=price_after_voucher, custom_price_input=custom_price, custom_price_input_is_net=self.event.settings.display_net_prices, voucher_ignored=voucher_ignored, ) self._check_item_constraints(op, operations) operations.append(op) self._quota_diff.update(quota_diff) self._voucher_use_diff += voucher_use_diff self._operations += operations def remove_item(self, pos_id: int): # TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more # flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction # could cancel each other out quota-wise). However, we are not taking this performance # penalty for now as there is currently no outside interface that would allow building # such a transaction. try: cp = self.positions.get(pk=pos_id) except CartPosition.DoesNotExist: raise CartError(error_messages['unknown_position']) self._operations.append(self.RemoveOperation(position=cp)) def clear(self): # TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more # flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction # could cancel each other out quota-wise). However, we are not taking this performance # penalty for now as there is currently no outside interface that would allow building # such a transaction. for cp in self.positions.filter(addon_to__isnull=True): self._operations.append(self.RemoveOperation(position=cp)) def set_addons(self, addons): self._update_items_cache( [a['item'] for a in addons], [a['variation'] for a in addons], ) # Prepare various containers to hold data later current_addons = defaultdict(lambda: defaultdict(list)) # CartPos -> currently attached add-ons input_addons = defaultdict(Counter) # CartPos -> final desired set of add-ons selected_addons = defaultdict(Counter) # CartPos, ItemAddOn -> final desired set of add-ons cpcache = {} # CartPos.pk -> CartPos quota_diff = Counter() # Quota -> Number of usages operations = [] available_categories = defaultdict(set) # CartPos -> Category IDs to choose from toplevel_cp = self.positions.filter( addon_to__isnull=True ).prefetch_related( 'addons', 'item__addons', 'item__addons__addon_category' ).select_related('item', 'variation') # Prefill some of the cache containers for cp in toplevel_cp: available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()} cpcache[cp.pk] = cp for a in cp.addons.all(): if not a.is_bundled: current_addons[cp][a.item_id, a.variation_id].append(a) # Create operations, perform various checks for a in addons: # Check whether the specified items are part of what we just fetched from the database # If they are not, the user supplied item IDs which either do not exist or belong to # a different event if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache): raise CartError(error_messages['not_for_sale']) # Only attach addons to things that are actually in this user's cart if a['addon_to'] not in cpcache: raise CartError(error_messages['addon_invalid_base']) cp = cpcache[a['addon_to']] item = self._items_cache[a['item']] variation = self._variations_cache[a['variation']] if a['variation'] is not None else None if item.category_id not in available_categories[cp.pk]: raise CartError(error_messages['addon_invalid_base']) # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. quotas = list(item.quotas.filter(subevent=cp.subevent) if variation is None else variation.quotas.filter(subevent=cp.subevent)) if not quotas: raise CartError(error_messages['unavailable']) if (a['item'], a['variation']) in input_addons[cp.id]: raise CartError(error_messages['addon_duplicate_item']) input_addons[cp.id][a['item'], a['variation']] = a.get('count', 1) selected_addons[cp.id, item.category_id][a['item'], a['variation']] = a.get('count', 1) if is_included_for_free(item, cp): listed_price = Decimal('0.00') else: listed_price = get_listed_price(item, variation, cp.subevent) custom_price = None if item.free_price and a.get('price'): custom_price = re.sub('[^0-9.,]', '', a.get('price')) if not custom_price: raise CartError(error_messages['price_not_a_number']) try: custom_price = forms.DecimalField(localize=True).to_python(custom_price) except: try: custom_price = Decimal(custom_price) except: raise CartError(error_messages['price_not_a_number']) if custom_price > 99_999_999_999: raise CartError(error_messages['price_too_high']) # Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky) for ca in current_addons[cp][a['item'], a['variation']]: if ca.listed_price != listed_price: ca.listed_price = ca.listed_price ca.price_after_voucher = ca.price_after_voucher ca.save(update_fields=['listed_price', 'price_after_voucher']) if ca.custom_price_input != custom_price: ca.custom_price_input = custom_price ca.custom_price_input_is_net = self.event.settings.display_net_prices ca.price_after_voucher = ca.price_after_voucher ca.save(update_fields=['custom_price_input', 'custom_price_input']) if a.get('count', 1) > len(current_addons[cp][a['item'], a['variation']]): # This add-on is new, add it to the cart for quota in quotas: quota_diff[quota] += a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]) op = self.AddOperation( count=a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]), item=item, variation=variation, voucher=None, quotas=quotas, addon_to=cp, subevent=cp.subevent, bundled=[], seat=None, listed_price=listed_price, price_after_voucher=listed_price, custom_price_input=custom_price, custom_price_input_is_net=self.event.settings.display_net_prices, voucher_ignored=False, ) self._check_item_constraints(op, operations) operations.append(op) # Check constraints on the add-on combinations for cp in toplevel_cp: item = cp.item for iao in item.addons.all(): selected = selected_addons[cp.id, iao.addon_category_id] n_per_i = Counter() for (i, v), c in selected.items(): n_per_i[i] += c if sum(selected.values()) > iao.max_count: raise CartError( error_messages['addon_max_count'] % { 'base': str(item.name), 'max': iao.max_count, 'cat': str(iao.addon_category.name), } ) elif sum(selected.values()) < iao.min_count: raise CartError( error_messages['addon_min_count'] % { 'base': str(item.name), 'min': iao.min_count, 'cat': str(iao.addon_category.name), } ) elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed: raise CartError( error_messages['addon_no_multi'] % { 'base': str(item.name), 'cat': str(iao.addon_category.name), } ) validate_cart_addons.send( sender=self.event, addons={ (self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None): c for s, c in selected.items() if c > 0 }, base_position=cp, iao=iao ) # Detect removed add-ons and create RemoveOperations for cp, al in list(current_addons.items()): for k, v in al.items(): input_num = input_addons[cp.id].get(k, 0) current_num = len(current_addons[cp].get(k, [])) if input_num < current_num: for a in current_addons[cp][k][:current_num - input_num]: if a.expires > self.real_now_dt: quotas = list(a.quotas) for quota in quotas: quota_diff[quota] -= 1 op = self.RemoveOperation(position=a) operations.append(op) self._quota_diff.update(quota_diff) self._operations += operations def _get_voucher_availability(self): vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability( self.event, self._voucher_use_diff, self.real_now_dt, exclude_position_ids=[ op.position.id for op in self._operations if isinstance(op, self.ExtendOperation) ] ) return vouchers_ok def _check_min_max_per_product(self): items = Counter() for p in self.positions: items[p.item] += 1 for op in self._operations: if isinstance(op, self.AddOperation): items[op.item] += op.count for bo in op.bundled: items[bo.item] += bo.count elif isinstance(op, self.RemoveOperation): items[op.position.item] -= 1 for a in op.position.addons.all(): items[a.item] -= 1 err = None for item, count in items.items(): if count == 0: continue if item.max_per_order and count > item.max_per_order: raise CartError( error_messages['max_items_per_product'] % { 'max': item.max_per_order, 'product': item.name } ) if item.min_per_order and count < item.min_per_order: self._operations = [o for o in self._operations if not ( isinstance(o, self.AddOperation) and o.item.pk == item.pk )] removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)] for p in self.positions: if p.item_id == item.pk and p.pk not in removals: self._operations.append(self.RemoveOperation(position=p)) err = error_messages['min_items_per_product_removed'] % { 'min': item.min_per_order, 'product': item.name } if not err: raise CartError( error_messages['min_items_per_product'] % { 'min': item.min_per_order, 'product': item.name } ) return err def _check_min_per_voucher(self): vouchers = Counter() for p in self.positions: vouchers[p.voucher] += 1 for op in self._operations: if isinstance(op, self.AddOperation): vouchers[op.voucher] += op.count elif isinstance(op, self.RemoveOperation): vouchers[op.position.voucher] -= 1 err = None for voucher, count in vouchers.items(): if not voucher or count == 0: continue if count < voucher.min_usages_remaining: self._operations = [o for o in self._operations if not ( isinstance(o, self.AddOperation) and o.voucher and o.voucher.pk == voucher.pk )] removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)] for p in self.positions: if p.voucher_id == voucher.pk and p.pk not in removals: self._operations.append(self.RemoveOperation(position=p)) err = error_messages['voucher_min_usages_removed'] % { 'voucher': voucher.code, 'number': voucher.min_usages_remaining, } if not err: raise CartError( error_messages['voucher_min_usages'] % { 'voucher': voucher.code, 'number': voucher.min_usages_remaining, } ) return err @transaction.atomic(durable=True) def _perform_operations(self): full_lock_required = any(getattr(o, 'seat', False) for o in self._operations) and self.event.settings.seating_minimal_distance > 0 if full_lock_required: # We lock the entire event in this case since we don't want to deal with fine-granular locking # in the case of seating distance enforcement lock_objects([self.event]) else: lock_objects( [q for q, d in self._quota_diff.items() if q.size is not None and d > 0] + [v for v, d in self._voucher_use_diff.items() if d > 0] + [getattr(o, 'seat', False) for o in self._operations if getattr(o, 'seat', False)], shared_lock_objects=[self.event] ) vouchers_ok = self._get_voucher_availability() quotas_ok = _get_quota_availability(self._quota_diff, self.real_now_dt) err = None new_cart_positions = [] deleted_positions = set() err = err or self._check_min_max_per_product() self._operations.sort(key=lambda a: self.order[type(a)]) seats_seen = set() if 'sleep-after-quota-check' in debugflags_var.get(): sleep(2) err_unavailable_products = [] for iop, op in enumerate(self._operations): if isinstance(op, self.RemoveOperation): if op.position.expires > self.real_now_dt: for q in op.position.quotas: quotas_ok[q] += 1 addons = op.position.addons.all() deleted_positions |= {a.pk for a in addons} addons.delete() deleted_positions.add(op.position.pk) op.position.delete() elif isinstance(op, (self.AddOperation, self.ExtendOperation)): # Create a CartPosition for as much items as we can requested_count = quota_available_count = voucher_available_count = op.count if op.seat: if op.seat in seats_seen: err = err or error_messages['seat_multiple'] seats_seen.add(op.seat) if op.quotas: quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas)) if op.voucher: voucher_available_count = min(voucher_available_count, vouchers_ok[op.voucher]) if quota_available_count < 1: err = err or error_messages['unavailable_listed'] err_unavailable_products.append( f'{op.item.name} – {op.variation}' if op.variation else op.item.name ) elif quota_available_count < requested_count: err = err or error_messages['in_part_listed'] err_unavailable_products.append( f'{op.item.name} – {op.variation}' if op.variation else op.item.name ) if voucher_available_count < 1: if op.voucher in self._voucher_depend_on_cart: err = err or (error_messages['voucher_redeemed_cart'] % self.event.settings.reservation_time) else: err = err or error_messages['voucher_redeemed'] elif voucher_available_count < requested_count: err = err or (error_messages['voucher_redeemed_partial'] % voucher_available_count) available_count = min(quota_available_count, voucher_available_count) if isinstance(op, self.AddOperation): for b in op.bundled: b_quotas = list(b.quotas) if not b_quotas: if not op.voucher or not op.voucher.allow_ignore_quota: err = err or error_messages['unavailable_listed'] err_unavailable_products.append( f'{op.item.name} – {op.variation}' if op.variation else op.item.name ) available_count = 0 continue b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas)) if b_quota_available_count < b.count: err = err or error_messages['unavailable_listed'] err_unavailable_products.append( f'{op.item.name} – {op.variation}' if op.variation else op.item.name ) available_count = 0 elif b_quota_available_count < available_count * b.count: err = err or error_messages['in_part_listed'] available_count = b_quota_available_count // b.count err_unavailable_products.append( f'{op.item.name} – {op.variation}' if op.variation else op.item.name ) for q in b_quotas: quotas_ok[q] -= available_count * b.count # TODO: is this correct? for q in op.quotas: quotas_ok[q] -= available_count if op.voucher: vouchers_ok[op.voucher] -= available_count if any(qa < 0 for qa in quotas_ok.values()): # Safeguard, shouldn't happen err = err or error_messages['unavailable'] available_count = 0 if isinstance(op, self.AddOperation): if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None, sales_channel=self._sales_channel, distance_ignore_cart_id=self.cart_id): available_count = 0 err = err or error_messages['seat_unavailable'] for k in range(available_count): line_price = get_line_price( price_after_voucher=op.price_after_voucher, custom_price_input=op.custom_price_input, custom_price_input_is_net=op.custom_price_input_is_net, tax_rule=op.item.tax_rule, invoice_address=self.invoice_address, bundled_sum=sum([pp.count * pp.price_after_voucher for pp in op.bundled]), ) cp = CartPosition( event=self.event, item=op.item, variation=op.variation, expires=self._expiry, max_extend=self._max_expiry_extend, cart_id=self.cart_id, voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None, subevent=op.subevent, seat=op.seat, listed_price=op.listed_price, price_after_voucher=op.price_after_voucher, custom_price_input=op.custom_price_input, custom_price_input_is_net=op.custom_price_input_is_net, line_price_gross=line_price.gross, tax_rate=line_price.rate, price=line_price.gross, ) if self.event.settings.attendee_names_asked: scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme) if 'attendee-name' in self._widget_data: cp.attendee_name_parts = {'_legacy': self._widget_data['attendee-name']} if any('attendee-name-{}'.format(k.replace('_', '-')) in self._widget_data for k, l, w in scheme['fields']): cp.attendee_name_parts = { k: self._widget_data.get('attendee-name-{}'.format(k.replace('_', '-')), '') for k, l, w in scheme['fields'] } if self.event.settings.attendee_emails_asked and 'email' in self._widget_data: cp.attendee_email = self._widget_data.get('email') cp._answers = {} for k, v in self._widget_data.items(): if not k.startswith('question-'): continue q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first() if q: try: cp._answers[q] = q.clean_answer(v) except ValidationError: pass if op.bundled: cp.save() # Needs to be in the database already so we have a PK that we can reference for b in op.bundled: bline_price = ( b.item.tax_rule or TaxRule(rate=Decimal('0.00')) ).tax(b.listed_price, base_price_is='gross', invoice_address=self.invoice_address) # todo compare with previous behaviour for j in range(b.count): new_cart_positions.append(CartPosition( event=self.event, item=b.item, variation=b.variation, expires=self._expiry, max_extend=self._max_expiry_extend, cart_id=self.cart_id, voucher=None, addon_to=cp, subevent=b.subevent, listed_price=b.listed_price, price_after_voucher=b.price_after_voucher, custom_price_input=b.custom_price_input, custom_price_input_is_net=b.custom_price_input_is_net, line_price_gross=bline_price.gross, tax_rate=bline_price.rate, price=bline_price.gross, is_bundled=True )) new_cart_positions.append(cp) elif isinstance(op, self.ExtendOperation): if op.seat and not op.seat.is_available(ignore_cart=op.position, sales_channel=self._sales_channel, ignore_voucher_id=op.position.voucher_id): err = err or error_messages['seat_unavailable'] addons = op.position.addons.all() deleted_positions |= {a.pk for a in addons} deleted_positions.add(op.position.pk) addons.delete() op.position.delete() elif available_count == 1: op.position.expires = self._expiry op.position.max_extend = self._max_expiry_extend op.position.listed_price = op.listed_price op.position.price_after_voucher = op.price_after_voucher # op.position.price will be updated by recompute_final_prices_and_taxes() if op.position.pk not in deleted_positions: try: op.position.save(force_update=True, update_fields=['expires', 'max_extend', 'listed_price', 'price_after_voucher']) self.num_extended_positions += 1 except DatabaseError: # Best effort... The position might have been deleted in the meantime! pass elif available_count == 0: addons = op.position.addons.all() deleted_positions |= {a.pk for a in addons} deleted_positions.add(op.position.pk) addons.delete() op.position.delete() else: raise AssertionError("ExtendOperation cannot affect more than one item") elif isinstance(op, self.VoucherOperation): if vouchers_ok[op.voucher] < 1: if iop == 0: raise CartError(error_messages['voucher_redeemed']) else: # We fail silently if we could only apply the voucher to part of the cart, since that might # be expected continue op.position.price_after_voucher = op.price_after_voucher op.position.voucher = op.voucher if op.position.custom_price_input and op.position.custom_price_input == op.position.listed_price: op.position.custom_price_input = op.price_after_voucher # op.position.price will be set in recompute_final_prices_and_taxes op.position.save(update_fields=['price_after_voucher', 'voucher', 'custom_price_input']) vouchers_ok[op.voucher] -= 1 if op.voucher.all_bundles_included or op.voucher.all_addons_included: for a in op.position.addons.all(): if a.is_bundled and op.voucher.all_bundles_included and a.price: a.listed_price = Decimal("0.00") a.price_after_voucher = Decimal("0.00") # a.price will be set in recompute_final_prices_and_taxes a.save(update_fields=['listed_price', 'price_after_voucher']) elif not a.is_bundled and op.voucher.all_addons_included and a.price and not a.custom_price_input: a.listed_price = Decimal("0.00") a.price_after_voucher = Decimal("0.00") # op.positon.price will be set in recompute_final_prices_and_taxes a.save(update_fields=['listed_price', 'price_after_voucher']) for p in new_cart_positions: if getattr(p, '_answers', None): if not p.pk: # We stored some to the database already before p.save() _save_answers(p, {}, p._answers) CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk]) if 'sleep-before-commit' in debugflags_var.get(): sleep(2) if err in (error_messages['unavailable_listed'], error_messages['in_part_listed']): err = err % ', '.join(str(p) for p in err_unavailable_products) return err def recompute_final_prices_and_taxes(self): positions = sorted(list(self.positions), key=lambda cp: (-(cp.addon_to_id or 0), cp.pk)) diff = Decimal('0.00') for cp in positions: if cp.listed_price is None: # migration from old system? also used in unit tests cp.update_listed_price_and_voucher() cp.migrate_free_price_if_necessary() cp.update_line_price(self.invoice_address, [b for b in positions if b.addon_to_id == cp.pk and b.is_bundled]) discount_results = apply_discounts( self.event, self._sales_channel.identifier, [ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in positions ] ) for cp, (new_price, discount) in zip(positions, discount_results): if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None): diff += new_price - cp.gross_price_before_rounding cp.price = new_price cp.price_includes_rounding_correction = Decimal("0.00") cp.discount = discount cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount']) return diff def commit(self): self._check_presale_dates() self._check_max_cart_size() err = self._delete_out_of_timeframe() err = self.extend_expired_positions() or err err = err or self._check_min_per_voucher() self._extend_expiry_of_valid_existing_positions() err = self._perform_operations() or err self.recompute_final_prices_and_taxes() if err: raise CartError(err) def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): """ :param cart_session: The current cart session. :param provider: The instance of your payment provider. :param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited. :param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged to use for payment providers which charge a payment fee, as this can be very user-unfriendly if users need a second payment method just for the payment fee of the first method. :param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute. :return: """ cart_session.setdefault('payments', []) cart_session['payments'].append({ 'id': str(uuid.uuid4()), 'provider': provider.identifier, 'multi_use_supported': provider.multi_use_supported, 'min_value': str(min_value) if min_value is not None else None, 'max_value': str(max_value) if max_value is not None else None, 'info_data': info_data or {}, }) def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): """ :param request: The current HTTP request context. :param provider: The instance of your payment provider. :param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited. :param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged to use for payment providers which charge a payment fee, as this can be very user-unfriendly if users need a second payment method just for the payment fee of the first method. :param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute. :return: """ from pretix.presale.views.cart import cart_session cs = cart_session(request) add_payment_to_cart_session(cs, provider, min_value, max_value, info_data) def get_fees(event, request, _total_ignored_=None, invoice_address=None, payments=None, positions=None): """ Return all fees that would be created for the current cart. Also implicitly applies rounding on the order positions. A recommended usage pattern to compute the total looks like this:: cart = get_cart(request) fees = get_fees( event=request.event, request=request, invoice_address=cached_invoice_address(request), payments=None, positions=cart, ) total = sum([c.price for c in cart]) + sum([f.value for f in fees]) """ if payments and not isinstance(payments, list): raise TypeError("payments must now be a list") if positions is None: raise TypeError("Must pass positions, parameter is only optional for backwards-compat reasons") fees = [] total = sum([c.gross_price_before_rounding for c in positions]) for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address, positions=positions, total=total, payment_requests=payments): if resp: fees += resp for fee in fees: fee._calculate_tax(invoice_address=invoice_address, event=event) if fee.tax_rule and not fee.tax_rule.pk: fee.tax_rule = None # TODO: deprecate apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees]) if total != 0 and payments: payments_assigned = Decimal("0.00") for p in payments: # This algorithm of treating min/max values and fees needs to stay in sync between the following # places in the code base: # - pretix.base.services.cart.get_fees # - pretix.base.services.orders._get_fees # - pretix.presale.views.CartMixin.current_selected_payments if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']): continue to_pay = max(total - payments_assigned, Decimal("0.00")) if p.get('max_value') and to_pay > Decimal(p['max_value']): to_pay = min(to_pay, Decimal(p['max_value'])) pprov = event.get_payment_providers(cached=True).get(p['provider']) if not pprov: continue payment_fee = pprov.calculate_fee(to_pay) if payment_fee: if event.settings.tax_rule_payment == "default": payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero() else: payment_fee_tax_rule = TaxRule.zero() payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address) pf = OrderFee( fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, tax_rate=payment_fee_tax.rate, tax_value=payment_fee_tax.tax, tax_code=payment_fee_tax.code, tax_rule=payment_fee_tax_rule ) fees.append(pf) # Re-apply rounding as grand total has changed apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees]) # Re-calculate to_pay as grand total has changed to_pay = max(total - payments_assigned, Decimal("0.00")) if p.get('max_value') and to_pay > Decimal(p['max_value']): to_pay = min(to_pay, Decimal(p['max_value'])) payments_assigned += to_pay return fees @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en', invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None: """ Adds a list of items to a user's cart. :param event: The event ID in question :param items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID :param cart_id: Session ID of a guest :raises CartError: On any error that occurred """ with language(locale), time_machine_now_assigned(override_now_dt): ia = False if invoice_address: try: with scopes_disabled(): ia = InvoiceAddress.objects.get(pk=invoice_address) except InvoiceAddress.DoesNotExist: pass try: sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) except SalesChannel.DoesNotExist: raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data, sales_channel=sales_channel) cm.add_new_items(items) cm.commit() except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ :param event: The event ID in question :param voucher: A voucher code :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): try: sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) except SalesChannel.DoesNotExist: raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm.apply_voucher(voucher) cm.commit() except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ Removes an item specified by its position ID from a user's cart. :param event: The event ID in question :param position: A cart position ID :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): try: sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) except SalesChannel.DoesNotExist: raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm.remove_item(position) cm.commit() except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ Removes all items from a user's cart. :param event: The event ID in question :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): try: sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) except SalesChannel.DoesNotExist: raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm.clear() cm.commit() except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict: """ Resets the expiry time of a cart to the configured reservation time of this event. Limited to 11x the reservation time. :param event: The event ID in question :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): try: sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) except SalesChannel.DoesNotExist: raise CartError("Invalid sales channel.") try: 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} except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en', invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None: """ Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to ensure the requested addon state. :param event: The event ID in question :param addons: A list of dicts with the keys addon_to, item, variation :param add_to_cart_items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): ia = False if invoice_address: try: with scopes_disabled(): ia = InvoiceAddress.objects.get(pk=invoice_address) except InvoiceAddress.DoesNotExist: pass try: sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) except SalesChannel.DoesNotExist: raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel) cm.set_addons(addons) cm.add_new_items(add_to_cart_items) cm.commit() except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) @receiver(checkout_confirm_messages, dispatch_uid="cart_confirm_messages") def confirm_messages(sender, *args, **kwargs): if not sender.settings.confirm_texts: return {} confirm_texts = sender.settings.get("confirm_texts", as_type=LazyI18nStringList) return {'confirm_text_%i' % index: rich_text(str(text)) for index, text in enumerate(confirm_texts)}