from collections import Counter from datetime import datetime, timedelta from decimal import Decimal from typing import List from celery.exceptions import MaxRetriesExceededError from django.db.models import Q from django.utils.timezone import now from django.utils.translation import ugettext as _ from pretix.base.i18n import LazyLocaleException from pretix.base.models import ( CartPosition, Event, Item, ItemVariation, Quota, Voucher, ) from pretix.base.services.async import ProfiledTask from pretix.base.services.locking import LockTimeoutException from pretix.celery import app class CartError(LazyLocaleException): pass 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.'), '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."), 'not_started': _('The presale period for this event has not yet started.'), 'ended': _('The presale period has ended.'), '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_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_required': _('You need a valid voucher code to order this product.'), } def _extend_existing(event: Event, cart_id: str, expiry: datetime, now_dt: datetime) -> None: # Extend this user's cart session to 30 minutes from now 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 CartPosition.objects.filter( Q(cart_id=cart_id) & Q(event=event) & Q(expires__gt=now_dt) ).update(expires=expiry) def _re_add_expired_positions(items: List[dict], event: Event, cart_id: str, now_dt: datetime) -> List[CartPosition]: positions = set() # For items that are already expired, we have to delete and re-add them, as they might # be no longer available or prices might have changed. Sorry! expired = CartPosition.objects.filter( Q(cart_id=cart_id) & Q(event=event) & Q(expires__lte=now_dt) ) for cp in expired: items.insert(0, { 'item': cp.item_id, 'variation': cp.variation_id, 'count': 1, 'price': cp.price, '_cp': cp, 'voucher': cp.voucher.code if cp.voucher else None }) positions.add(cp) return positions def _delete_expired(expired: List[CartPosition], now_dt: datetime) -> None: for cp in expired: if cp.expires <= now_dt: # Has not been extended cp.delete() def _check_date(event: Event, now_dt: datetime) -> None: if event.presale_start and now_dt < event.presale_start: raise CartError(error_messages['not_started']) if event.presale_end and now_dt > event.presale_end: raise CartError(error_messages['ended']) def _parse_items_and_check_constraints(event: Event, items: List[dict], cart_id: str, now_dt: datetime) -> Counter: """ This method does three things: * Extend the item list with the database objects for the item, variation, etc. * Check all constraints that are placed on the items, vouchers etc. to be valid and calculates the correct prices * Return a counter object that contains the quota changes that are required to perform the operation """ err = None # Fetch items from the database items_query = Item.objects.filter(event=event, id__in=[i['item'] for i in items]).prefetch_related( "quotas") items_cache = {i.id: i for i in items_query} variations_query = ItemVariation.objects.filter( item__event=event, id__in=[i['variation'] for i in items if i['variation'] is not None] ).select_related("item", "item__event").prefetch_related("quotas") variations_cache = {v.id: v for v in variations_query} quotadiff = Counter() for i in items: # 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 items_cache or (i['variation'] is not None and i['variation'] not in variations_cache): err = err or error_messages['not_for_sale'] continue item = items_cache[i['item']] variation = variations_cache[i['variation']] if i['variation'] is not None else None # Check whether a voucher has been provided voucher = None if i.get('voucher'): try: voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event) if voucher.redeemed >= voucher.max_usages: return error_messages['voucher_redeemed'] if voucher.valid_until is not None and voucher.valid_until < now_dt: raise CartError(error_messages['voucher_expired']) if not voucher.applies_to(item, variation): return error_messages['voucher_invalid_item'] redeemed_in_carts = CartPosition.objects.filter( Q(voucher=voucher) & Q(event=event) & (Q(expires__gte=now_dt) | Q(cart_id=cart_id)) ) if 'cp' in i: redeemed_in_carts = redeemed_in_carts.exclude(pk=i['cp'].pk) v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count() if v_avail < 1: return error_messages['voucher_redeemed'] if i['count'] > v_avail: return error_messages['voucher_redeemed_partial'] % v_avail except Voucher.DoesNotExist: raise CartError(error_messages['voucher_invalid']) # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all()) if voucher and voucher.quota and voucher.quota.pk not in [q.pk for q in quotas]: raise CartError(error_messages['voucher_invalid_item']) if item.require_voucher and voucher is None: raise CartError(error_messages['voucher_required']) if item.hide_without_voucher and (voucher is None or voucher.item is None or voucher.item.pk != item.pk): raise CartError(error_messages['voucher_required']) if len(quotas) == 0 or not item.is_available() or (variation and not variation.active): err = err or error_messages['unavailable'] continue if voucher and voucher.price is not None: price = voucher.price else: price = item.default_price if variation is None else ( variation.default_price if variation.default_price is not None else item.default_price) if item.free_price and 'price' in i and i['price'] is not None and i['price'] != "": custom_price = i['price'] if not isinstance(custom_price, Decimal): custom_price = Decimal(custom_price.replace(",", ".")) if custom_price > 100000000: raise CartError(error_messages['price_too_high']) price = max(custom_price, price) # Check that all quotas allow us to buy i['count'] instances of the object if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota): for quota in quotas: quotadiff[quota] += i['count'] i['_quotas'] = quotas else: i['_quotas'] = [] i['_price'] = price i['_item'] = item i['_variation'] = variation i['_voucher'] = voucher if err: raise CartError(err) return quotadiff def _check_quota_and_create_positions(event: Event, items: List[dict], cart_id: str, now_dt: datetime, expiry: datetime, quotadiff: Counter,): """ This method takes the modified items and the quotadiff from _parse_items_and_check_constraints and then * checks that the given quotas are available * creates as many cart positions as possible """ err = None quotas_ok = {} cartpositions = [] with event.lock(): for quota, count in quotadiff.items(): avail = quota.availability(now_dt) if avail[1] is not None and avail[1] < count: # This quota is not available or less than i['count'] items are left, so we have to # reduce the number of bought items if avail[0] != Quota.AVAILABILITY_OK: err = err or error_messages['unavailable'] else: err = err or error_messages['in_part'] quotas_ok[quota] = min(count, avail[1]) else: quotas_ok[quota] = count for i in items: # Create a CartPosition for as much items as we can requested_count = i['count'] available_count = requested_count if i['_quotas']: available_count = min(requested_count, min(quotas_ok[q] for q in i['_quotas'])) for q in i['_quotas']: quotas_ok[q] -= available_count for k in range(available_count): if '_cp' in i and i['count'] == 1: # Recreating an existing position cp = i['_cp'] cp.expires = expiry cp.price = i['_price'] cp.save() else: cartpositions.append(CartPosition( event=event, item=i['_item'], variation=i['_variation'], price=i['_price'], expires=expiry, cart_id=cart_id, voucher=i['_voucher'] )) CartPosition.objects.bulk_create(cartpositions) if err: raise CartError(err) def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None: now_dt = now() _check_date(event, now_dt) existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count() if sum(i['count'] for i in items) + existing > int(event.settings.max_items_per_order): # TODO: i18n plurals raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,)) expiry = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int)) _extend_existing(event, cart_id, expiry, now_dt) expired = _re_add_expired_positions(items, event, cart_id, now_dt) try: if items: quotadiff = _parse_items_and_check_constraints(event, items, cart_id, now_dt) _check_quota_and_create_positions(event, items, cart_id, now_dt, expiry, quotadiff) except CartError as e: _delete_expired(expired, now_dt) raise e else: _delete_expired(expired, now_dt) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1) def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) -> None: """ Adds a list of items to a user's cart. :param event: The event ID in question :param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher) :param session: Session ID of a guest :param coupon: A coupon that should also be reeemed :raises CartError: On any error that occured """ event = Event.objects.get(id=event) try: try: _add_items_to_cart(event, items, cart_id) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy']) def _remove_items_from_cart(event: Event, items: List[dict], cart_id: str) -> None: with event.lock(): for i in items: cw = Q(cart_id=cart_id) & Q(item_id=i['item']) & Q(event=event) if i['variation']: cw &= Q(variation_id=i['variation']) else: cw &= Q(variation__isnull=True) # Prefer to delete positions that have the same price as the one the user clicked on, after thet # prefer the most expensive ones. cnt = i['count'] if i['price']: correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt] for cp in correctprice: cp.delete() cnt -= len(correctprice) if cnt > 0: for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]: cp.delete() @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1) def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None) -> None: """ Removes a list of items from a user's cart. :param event: The event ID in question :param items: A list of tuple of the form (item id, variation id or None, number) :param session: Session ID of a guest """ event = Event.objects.get(id=event) try: try: _remove_items_from_cart(event, items, cart_id) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): raise CartError(error_messages['busy'])