mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
1125 lines
52 KiB
Python
1125 lines
52 KiB
Python
from collections import Counter, defaultdict, namedtuple
|
|
from datetime import datetime, time, timedelta
|
|
from decimal import Decimal
|
|
from typing import List, Optional
|
|
|
|
from celery.exceptions import MaxRetriesExceededError
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import DatabaseError, transaction
|
|
from django.db.models import Count, Exists, OuterRef, Q
|
|
from django.dispatch import receiver
|
|
from django.utils.timezone import make_aware, now
|
|
from django.utils.translation import pgettext_lazy, ugettext as _
|
|
from django_scopes import scopes_disabled
|
|
|
|
from pretix.base.i18n import language
|
|
from pretix.base.models import (
|
|
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
|
|
SeatCategoryMapping, Voucher,
|
|
)
|
|
from pretix.base.models.event import SubEvent
|
|
from pretix.base.models.orders import OrderFee
|
|
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
|
from pretix.base.reldate import RelativeDateWrapper
|
|
from pretix.base.services.checkin import _save_answers
|
|
from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
|
from pretix.base.services.pricing import get_price
|
|
from pretix.base.services.tasks import ProfiledEventTask
|
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
|
from pretix.base.signals import validate_cart_addons
|
|
from pretix.base.templatetags.rich_text import rich_text
|
|
from pretix.celery_app import app
|
|
from pretix.presale.signals import (
|
|
checkout_confirm_messages, fee_calculation_for_cart,
|
|
)
|
|
|
|
|
|
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:
|
|
msg = _(msg)
|
|
super().__init__(msg)
|
|
|
|
|
|
error_messages = {
|
|
'busy': _('We were not able to process your request completely as the '
|
|
'server was too busy. Please try again.'),
|
|
'empty': _('You did not select any products.'),
|
|
'unknown_position': _('Unknown cart position.'),
|
|
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
|
'not_for_sale': _('You selected a product which is not available for sale.'),
|
|
'unavailable': _('Some of the products you selected are no longer available. '
|
|
'Please see below for details.'),
|
|
'in_part': _('Some of the products you selected are no longer available in '
|
|
'the quantity you selected. Please see below for details.'),
|
|
'max_items': _("You cannot select more than %s items per order."),
|
|
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
|
|
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
|
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
|
"%(min)s items of it."),
|
|
'not_started': _('The presale period for this event has not yet started.'),
|
|
'ended': _('The presale period for this event has ended.'),
|
|
'some_subevent_not_started': _('The presale period for this event has not yet started. The affected positions '
|
|
'have been removed from your cart.'),
|
|
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
|
'positions have been removed from your cart.'),
|
|
'price_too_high': _('The entered price is to high.'),
|
|
'voucher_invalid': _('This voucher code is not known in our database.'),
|
|
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
|
'voucher_redeemed_cart': _('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': _('This voucher code can only be redeemed %d more times.'),
|
|
'voucher_double': _('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': _('This voucher is expired.'),
|
|
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
|
'voucher_item_not_available': _(
|
|
'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': _('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': _('You can not select an add-on for the selected product.'),
|
|
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
|
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
|
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
|
'product %(base)s.'),
|
|
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
|
|
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
|
|
'seat_required': _('You need to select a specific seat.'),
|
|
'seat_invalid': _('Please select a valid seat.'),
|
|
'seat_forbidden': _('You can not select a seat for this position.'),
|
|
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
|
|
'seat_multiple': _('You can not select the same seat multiple times.'),
|
|
}
|
|
|
|
|
|
class CartManager:
|
|
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
|
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
|
|
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
|
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
|
'quotas', 'subevent', 'seat'))
|
|
order = {
|
|
RemoveOperation: 10,
|
|
ExtendOperation: 20,
|
|
AddOperation: 30
|
|
}
|
|
|
|
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None,
|
|
sales_channel='web'):
|
|
self.event = event
|
|
self.cart_id = cart_id
|
|
self.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._expiry = None
|
|
self.invoice_address = invoice_address
|
|
self._widget_data = widget_data or {}
|
|
self._sales_channel = sales_channel
|
|
|
|
@property
|
|
def positions(self):
|
|
return CartPosition.objects.filter(
|
|
Q(cart_id=self.cart_id) & Q(event=self.event)
|
|
).select_related('item', 'subevent')
|
|
|
|
def _is_seated(self, item, subevent):
|
|
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 _calculate_expiry(self):
|
|
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
|
|
|
def _check_presale_dates(self):
|
|
if self.event.presale_start and self.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 < self.now_dt:
|
|
raise CartError(error_messages['ended'])
|
|
|
|
def _extend_expiry_of_valid_existing_positions(self):
|
|
# 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
|
|
self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry)
|
|
|
|
def _delete_out_of_timeframe(self):
|
|
err = None
|
|
for cp in self.positions:
|
|
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
|
err = error_messages['some_subevent_not_started']
|
|
cp.addons.all().delete()
|
|
cp.delete()
|
|
|
|
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
|
err = error_messages['some_subevent_ended']
|
|
cp.addons.all().delete()
|
|
cp.delete()
|
|
|
|
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 < self.now_dt:
|
|
err = error_messages['some_subevent_ended']
|
|
cp.addons.all().delete()
|
|
cp.delete()
|
|
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._items_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]
|
|
)
|
|
})
|
|
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]
|
|
)
|
|
})
|
|
|
|
def _check_max_cart_size(self):
|
|
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])
|
|
if cartsize > int(self.event.settings.max_items_per_order):
|
|
# TODO: i18n plurals
|
|
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
|
|
|
def _check_item_constraints(self, op):
|
|
if isinstance(op, self.AddOperation) or isinstance(op, 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:
|
|
raise CartError(error_messages['voucher_required'])
|
|
|
|
if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items):
|
|
raise CartError(error_messages['voucher_required'])
|
|
|
|
if not op.item.is_available() or (op.variation and not op.variation.active):
|
|
raise CartError(error_messages['unavailable'])
|
|
|
|
if self._sales_channel not in op.item.sales_channels:
|
|
raise CartError(error_messages['unavailable'])
|
|
|
|
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.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 self.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):
|
|
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 < self.now_dt:
|
|
raise CartError(error_messages['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'])
|
|
|
|
if op.item.max_per_order or op.item.min_per_order:
|
|
new_total = (
|
|
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
|
sum([_op.count for _op in self._operations
|
|
if isinstance(_op, self.AddOperation) and _op.item == op.item]) +
|
|
op.count -
|
|
len([1 for _op in self._operations
|
|
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
|
)
|
|
|
|
if op.item.max_per_order and new_total > op.item.max_per_order:
|
|
raise CartError(
|
|
_(error_messages['max_items_per_product']) % {
|
|
'max': op.item.max_per_order,
|
|
'product': op.item.name
|
|
}
|
|
)
|
|
|
|
if op.item.min_per_order and new_total < op.item.min_per_order:
|
|
raise CartError(
|
|
_(error_messages['min_items_per_product']) % {
|
|
'min': op.item.min_per_order,
|
|
'product': op.item.name
|
|
}
|
|
)
|
|
|
|
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 ValueError as e:
|
|
if str(e) == 'price_too_high':
|
|
raise CartError(error_messages['price_too_high'])
|
|
else:
|
|
raise e
|
|
|
|
def extend_expired_positions(self):
|
|
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
|
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
|
|
).annotate(
|
|
requires_seat=Exists(
|
|
SeatCategoryMapping.objects.filter(
|
|
Q(product=OuterRef('item'))
|
|
& (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True))
|
|
)
|
|
)
|
|
).prefetch_related(
|
|
'item__quotas',
|
|
'variation__quotas',
|
|
'addons'
|
|
).order_by('-is_bundled')
|
|
err = None
|
|
changed_prices = {}
|
|
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 = cp.requires_seat
|
|
|
|
if cp.is_bundled:
|
|
try:
|
|
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
|
price = bundle.designated_price or 0
|
|
except ItemBundle.DoesNotExist:
|
|
price = cp.price
|
|
|
|
changed_prices[cp.pk] = price
|
|
|
|
if not cp.includes_tax:
|
|
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
|
|
force_custom_price=True, cp_is_net=False)
|
|
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
|
else:
|
|
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
|
|
force_custom_price=True)
|
|
else:
|
|
bundled_sum = Decimal('0.00')
|
|
if not cp.addon_to_id:
|
|
for bundledp in cp.addons.all():
|
|
if bundledp.is_bundled:
|
|
bundledprice = changed_prices.get(bundledp.pk, bundledp.price)
|
|
bundled_sum += bundledprice
|
|
|
|
if not cp.includes_tax:
|
|
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
|
cp_is_net=True, bundled_sum=bundled_sum)
|
|
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
|
else:
|
|
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
|
bundled_sum=bundled_sum)
|
|
|
|
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,
|
|
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
|
|
)
|
|
self._check_item_constraints(op)
|
|
|
|
if cp.voucher:
|
|
self._voucher_use_diff[cp.voucher] += 1
|
|
|
|
self._operations.append(op)
|
|
return err
|
|
|
|
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'):
|
|
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
|
|
|
|
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']
|
|
|
|
# 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 = []
|
|
bundled_sum = Decimal('0.00')
|
|
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 bundle.designated_price:
|
|
bprice = self._get_price(bitem, bvar, None, bundle.designated_price, subevent, force_custom_price=True,
|
|
cp_is_net=False)
|
|
else:
|
|
bprice = TAXED_ZERO
|
|
bundled_sum += bundle.designated_price * bundle.count
|
|
|
|
bop = self.AddOperation(
|
|
count=bundle.count, item=bitem, variation=bvar, price=bprice,
|
|
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
|
|
includes_tax=bool(bprice.rate), bundled=[], seat=None
|
|
)
|
|
self._check_item_constraints(bop)
|
|
bundled.append(bop)
|
|
|
|
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
|
|
|
|
op = self.AddOperation(
|
|
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
|
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
|
|
)
|
|
self._check_item_constraints(op)
|
|
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(dict) # CartPos -> currently attached add-ons
|
|
input_addons = defaultdict(set) # CartPos -> add-ons according to input
|
|
selected_addons = defaultdict(set) # CartPos -> 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
|
|
price_included = defaultdict(dict) # CartPos -> CategoryID -> bool(price is included)
|
|
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()}
|
|
price_included[cp.pk] = {iao.addon_category_id: iao.price_included for iao in cp.item.addons.all()}
|
|
cpcache[cp.pk] = cp
|
|
current_addons[cp] = {
|
|
(a.item_id, a.variation_id): a
|
|
for a in cp.addons.all()
|
|
if not a.is_bundled
|
|
}
|
|
|
|
# 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'])
|
|
|
|
# Every item can be attached to very CartPosition at most once
|
|
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
|
|
raise CartError(error_messages['addon_duplicate_item'])
|
|
|
|
input_addons[cp.id].add((a['item'], a['variation']))
|
|
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
|
|
|
|
if (a['item'], a['variation']) not in current_addons[cp]:
|
|
# This add-on is new, add it to the cart
|
|
for quota in quotas:
|
|
quota_diff[quota] += 1
|
|
|
|
if price_included[cp.pk].get(item.category_id):
|
|
price = TAXED_ZERO
|
|
else:
|
|
price = self._get_price(item, variation, None, None, cp.subevent)
|
|
|
|
op = self.AddOperation(
|
|
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
|
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None
|
|
)
|
|
self._check_item_constraints(op)
|
|
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]
|
|
if len(selected) > iao.max_count:
|
|
# TODO: Proper i18n
|
|
# TODO: Proper pluralization
|
|
raise CartError(
|
|
error_messages['addon_max_count'],
|
|
{
|
|
'base': str(item.name),
|
|
'max': iao.max_count,
|
|
'cat': str(iao.addon_category.name),
|
|
}
|
|
)
|
|
elif len(selected) < iao.min_count:
|
|
# TODO: Proper i18n
|
|
# TODO: Proper pluralization
|
|
raise CartError(
|
|
error_messages['addon_min_count'],
|
|
{
|
|
'base': str(item.name),
|
|
'min': iao.min_count,
|
|
'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)
|
|
for s in selected
|
|
},
|
|
base_position=cp,
|
|
iao=iao
|
|
)
|
|
|
|
# Detect removed add-ons and create RemoveOperations
|
|
for cp, al in current_addons.items():
|
|
for k, v in al.items():
|
|
if k not in input_addons[cp.id]:
|
|
if v.expires > self.now_dt:
|
|
quotas = list(v.quotas)
|
|
|
|
for quota in quotas:
|
|
quota_diff[quota] -= 1
|
|
|
|
op = self.RemoveOperation(position=v)
|
|
operations.append(op)
|
|
|
|
self._quota_diff.update(quota_diff)
|
|
self._operations += operations
|
|
|
|
def _get_quota_availability(self):
|
|
quotas_ok = defaultdict(int)
|
|
for quota, count in self._quota_diff.items():
|
|
if count <= 0:
|
|
quotas_ok[quota] = 0
|
|
avail = quota.availability(self.now_dt)
|
|
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(self):
|
|
vouchers_ok = {}
|
|
self._voucher_depend_on_cart = set()
|
|
for voucher, count in self._voucher_use_diff.items():
|
|
voucher.refresh_from_db()
|
|
|
|
if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
|
|
raise CartError(error_messages['voucher_expired'])
|
|
|
|
redeemed_in_carts = CartPosition.objects.filter(
|
|
Q(voucher=voucher) & Q(event=self.event) &
|
|
Q(expires__gte=self.now_dt)
|
|
).exclude(pk__in=[
|
|
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
|
])
|
|
cart_count = redeemed_in_carts.count()
|
|
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
|
if cart_count > 0:
|
|
self._voucher_depend_on_cart.add(voucher)
|
|
vouchers_ok[voucher] = v_avail
|
|
|
|
return vouchers_ok
|
|
|
|
def _check_min_per_product(self):
|
|
per_product = Counter()
|
|
min_per_product = {}
|
|
for p in self.positions:
|
|
per_product[p.item_id] += 1
|
|
min_per_product[p.item.pk] = p.item.min_per_order
|
|
|
|
for op in self._operations:
|
|
if isinstance(op, self.AddOperation):
|
|
per_product[op.item.pk] += op.count
|
|
min_per_product[op.item.pk] = op.item.min_per_order
|
|
elif isinstance(op, self.RemoveOperation):
|
|
per_product[op.position.item_id] -= 1
|
|
min_per_product[op.position.item.pk] = op.position.item.min_per_order
|
|
|
|
err = None
|
|
for itemid, num in per_product.items():
|
|
min_p = min_per_product[itemid]
|
|
if min_p and num < min_p:
|
|
self._operations = [o for o in self._operations if not (
|
|
isinstance(o, self.AddOperation) and o.item.pk == itemid
|
|
)]
|
|
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
|
|
for p in self.positions:
|
|
if p.item_id == itemid and p.pk not in removals:
|
|
self._operations.append(self.RemoveOperation(position=p))
|
|
err = _(error_messages['min_items_per_product_removed']) % {
|
|
'min': min_p,
|
|
'product': p.item.name
|
|
}
|
|
|
|
return err
|
|
|
|
def _perform_operations(self):
|
|
vouchers_ok = self._get_voucher_availability()
|
|
quotas_ok = self._get_quota_availability()
|
|
err = None
|
|
new_cart_positions = []
|
|
|
|
err = err or self._check_min_per_product()
|
|
|
|
self._operations.sort(key=lambda a: self.order[type(a)])
|
|
seats_seen = set()
|
|
|
|
for op in self._operations:
|
|
if isinstance(op, self.RemoveOperation):
|
|
if op.position.expires > self.now_dt:
|
|
for q in op.position.quotas:
|
|
quotas_ok[q] += 1
|
|
op.position.addons.all().delete()
|
|
op.position.delete()
|
|
|
|
elif isinstance(op, self.AddOperation) or isinstance(op, 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']
|
|
elif quota_available_count < requested_count:
|
|
err = err or error_messages['in_part']
|
|
|
|
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']
|
|
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']
|
|
available_count = 0
|
|
elif b_quota_available_count < available_count * b.count:
|
|
err = err or error_messages['in_part']
|
|
available_count = b_quota_available_count // b.count
|
|
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():
|
|
available_count = 0
|
|
err = err or error_messages['seat_unavailable']
|
|
|
|
for k in range(available_count):
|
|
cp = CartPosition(
|
|
event=self.event, item=op.item, variation=op.variation,
|
|
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
|
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
|
|
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
|
|
)
|
|
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:
|
|
for j in range(b.count):
|
|
new_cart_positions.append(CartPosition(
|
|
event=self.event, item=b.item, variation=b.variation,
|
|
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
|
voucher=None, addon_to=cp,
|
|
subevent=b.subevent, includes_tax=b.includes_tax, 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):
|
|
err = err or error_messages['seat_unavailable']
|
|
op.position.addons.all().delete()
|
|
op.position.delete()
|
|
elif available_count == 1:
|
|
op.position.expires = self._expiry
|
|
op.position.price = op.price.gross
|
|
try:
|
|
op.position.save(force_update=True)
|
|
except DatabaseError:
|
|
# Best effort... The position might have been deleted in the meantime!
|
|
pass
|
|
elif available_count == 0:
|
|
op.position.addons.all().delete()
|
|
op.position.delete()
|
|
else:
|
|
raise AssertionError("ExtendOperation cannot affect more than one item")
|
|
|
|
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])
|
|
return err
|
|
|
|
def _require_locking(self):
|
|
if self._voucher_use_diff:
|
|
# If any vouchers are used, we lock to make sure we don't redeem them to often
|
|
return True
|
|
|
|
if self._quota_diff and any(q.size is not None for q in self._quota_diff):
|
|
# If any quotas are affected that are not unlimited, we lock
|
|
return True
|
|
|
|
if any(getattr(o, 'seat', False) for o in self._operations):
|
|
return True
|
|
|
|
return False
|
|
|
|
def commit(self):
|
|
self._check_presale_dates()
|
|
self._check_max_cart_size()
|
|
self._calculate_expiry()
|
|
|
|
err = self._delete_out_of_timeframe()
|
|
err = self.extend_expired_positions() or err
|
|
|
|
lockfn = NoLockManager
|
|
if self._require_locking():
|
|
lockfn = self.event.lock
|
|
|
|
with lockfn() as now_dt:
|
|
with transaction.atomic():
|
|
self.now_dt = now_dt
|
|
self._extend_expiry_of_valid_existing_positions()
|
|
err = self._perform_operations() or err
|
|
if err:
|
|
raise CartError(err)
|
|
|
|
|
|
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
|
|
positions = CartPosition.objects.filter(
|
|
cart_id=cart_id, event=event
|
|
).select_related('item', 'item__tax_rule')
|
|
totaldiff = Decimal('0.00')
|
|
for pos in positions:
|
|
if not pos.item.tax_rule:
|
|
continue
|
|
charge_tax = pos.item.tax_rule.tax_applicable(invoice_address)
|
|
if pos.includes_tax and not charge_tax:
|
|
price = pos.item.tax(pos.price, base_price_is='gross').net
|
|
totaldiff += price - pos.price
|
|
pos.price = price
|
|
pos.includes_tax = False
|
|
pos.save(update_fields=['price', 'includes_tax'])
|
|
elif charge_tax and not pos.includes_tax:
|
|
price = pos.item.tax(pos.price, base_price_is='net').gross
|
|
totaldiff += price - pos.price
|
|
pos.price = price
|
|
pos.includes_tax = True
|
|
pos.save(update_fields=['price', 'includes_tax'])
|
|
|
|
return totaldiff
|
|
|
|
|
|
def get_fees(event, request, total, invoice_address, provider):
|
|
from pretix.presale.views.cart import cart_session
|
|
|
|
fees = []
|
|
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
|
|
total=total):
|
|
if resp:
|
|
fees += resp
|
|
|
|
total = total + sum(f.value for f in fees)
|
|
|
|
cs = cart_session(request)
|
|
if cs.get('gift_cards'):
|
|
gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency)
|
|
summed = 0
|
|
for gc in gc_qs:
|
|
fval = Decimal(gc.value) # TODO: don't require an extra query
|
|
fval = min(fval, total - summed)
|
|
if fval > 0:
|
|
total -= fval
|
|
summed += fval
|
|
fees.append(OrderFee(
|
|
fee_type=OrderFee.FEE_TYPE_GIFTCARD,
|
|
internal_type='giftcard',
|
|
description=gc.secret,
|
|
value=-1 * fval,
|
|
tax_rate=Decimal('0.00'),
|
|
tax_value=Decimal('0.00'),
|
|
tax_rule=TaxRule.zero()
|
|
))
|
|
|
|
if provider and total != 0:
|
|
provider = event.get_payment_providers().get(provider)
|
|
if provider:
|
|
payment_fee = provider.calculate_fee(total)
|
|
|
|
if payment_fee:
|
|
payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero()
|
|
if payment_fee_tax_rule.tax_applicable(invoice_address):
|
|
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross')
|
|
fees.append(OrderFee(
|
|
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
|
value=payment_fee,
|
|
tax_rate=payment_fee_tax.rate,
|
|
tax_value=payment_fee_tax.tax,
|
|
tax_rule=payment_fee_tax_rule
|
|
))
|
|
else:
|
|
fees.append(OrderFee(
|
|
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
|
value=payment_fee,
|
|
tax_rate=Decimal('0.00'),
|
|
tax_value=Decimal('0.00'),
|
|
tax_rule=payment_fee_tax_rule
|
|
))
|
|
|
|
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') -> 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 occured
|
|
"""
|
|
with language(locale):
|
|
ia = False
|
|
if invoice_address:
|
|
try:
|
|
with scopes_disabled():
|
|
ia = InvoiceAddress.objects.get(pk=invoice_address)
|
|
except InvoiceAddress.DoesNotExist:
|
|
pass
|
|
|
|
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 remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en') -> None:
|
|
"""
|
|
Removes a list of items from a user's cart.
|
|
:param event: The event ID in question
|
|
:param position: A cart position ID
|
|
:param session: Session ID of a guest
|
|
"""
|
|
with language(locale):
|
|
try:
|
|
try:
|
|
cm = CartManager(event=event, cart_id=cart_id)
|
|
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') -> None:
|
|
"""
|
|
Removes a list of items from a user's cart.
|
|
:param event: The event ID in question
|
|
:param session: Session ID of a guest
|
|
"""
|
|
with language(locale):
|
|
try:
|
|
try:
|
|
cm = CartManager(event=event, cart_id=cart_id)
|
|
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 set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
|
|
invoice_address: int=None, sales_channel='web') -> None:
|
|
"""
|
|
Removes a list of items from a user's cart.
|
|
:param event: The event ID in question
|
|
:param addons: A list of dicts with the keys addon_to, item, variation
|
|
:param session: Session ID of a guest
|
|
"""
|
|
with language(locale):
|
|
ia = False
|
|
if invoice_address:
|
|
try:
|
|
with scopes_disabled():
|
|
ia = InvoiceAddress.objects.get(pk=invoice_address)
|
|
except InvoiceAddress.DoesNotExist:
|
|
pass
|
|
try:
|
|
try:
|
|
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
|
|
cm.set_addons(addons)
|
|
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_text:
|
|
return {}
|
|
|
|
return {
|
|
'confirm_text': rich_text(str(sender.settings.confirm_text))
|
|
}
|