From f3e03deba46eec7563f263bb560a654de732c413 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 27 Sep 2015 20:51:14 +0200 Subject: [PATCH] Refactored cart actions into pretix.base.services --- src/pretix/base/services/cart.py | 192 ++++++++++++++++++++++++++++++ src/pretix/presale/views/cart.py | 182 +++------------------------- src/pretix/presale/views/event.py | 11 +- 3 files changed, 214 insertions(+), 171 deletions(-) create mode 100644 src/pretix/base/services/cart.py diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py new file mode 100644 index 0000000000..a42947a503 --- /dev/null +++ b/src/pretix/base/services/cart.py @@ -0,0 +1,192 @@ +from datetime import timedelta + +from django.db.models import Q +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import ( + CartPosition, Event, EventLock, Item, ItemVariation, Quota, User, +) + + +class CartError(Exception): + 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 items.'), + 'not_for_sale': _('You selected a product which is not available for sale.'), + 'unavailable': _('Some of the products you selected were no longer available. ' + 'Please see below for details.'), + 'in_part': _('Some of the products you selected were 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.') +} + + +def _user_cart_q(user=None, guest_session=None): + if user and user.is_authenticated(): + return Q(Q(user=user) | Q(session=guest_session)) + return Q(Q(user__isnull=True) & Q(session=guest_session)) + + +def _extend_existing(event, user, guest_session, expiry): + # 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.current.filter( + _user_cart_q(user, guest_session) & Q(event=event) & Q(expires__gt=now()) + ).update(expires=expiry) + + +def _re_add_expired_positions(items, event, user, guest_session): + 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.current.filter( + _user_cart_q(user, guest_session) & Q(event=event) & Q(expires__lte=now()) + ) + for cp in expired: + items.insert(0, (cp.item_id, cp.variation_id, 1, cp)) + positions.add(cp) + return positions + + +def _delete_expired(expired): + for cp in expired: + if cp.version_end_date is None: + cp.delete() + + +def _check_date(event): + if event.presale_start and now() < event.presale_start: + raise CartError(error_messages['not_started']) + if event.presale_end and now() > event.presale_end: + raise CartError(error_messages['ended']) + + +def _add_items(event, items, user, guest_session, expiry): + err = None + + # Fetch items from the database + items_query = Item.objects.current.filter(event=event, identity__in=[i[0] for i in items]).prefetch_related( + "quotas") + items_cache = {i.identity: i for i in items_query} + variations_query = ItemVariation.objects.current.filter( + item__event=event, + identity__in=[i[1] for i in items if i[1] is not None] + ).select_related("item", "item__event").prefetch_related("quotas", "values", "values__prop") + variations_cache = {v.identity: v for v in variations_query} + + 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[0] not in items_cache or (i[1] is not None and i[1] not in variations_cache): + err = err or error_messages['not_for_sale'] + continue + + item = items_cache[i[0]] + variation = variations_cache[i[1]] if i[1] is not None else None + + # Execute restriction plugins to check whether they (a) change the price or + # (b) make the item/variation unavailable. If neither is the case, check_restriction + # will correctly return the default price + price = item.check_restrictions() if variation is None else variation.check_restrictions() + + # 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 price is False or len(quotas) == 0 or not item.active: + err = err or error_messages['unavailable'] + continue + + # Assume that all quotas allow us to buy i[2] instances of the object + quota_ok = i[2] + for quota in quotas: + avail = quota.availability() + if avail[1] < i[2]: + # This quota is not available or less than i[2] 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'] + quota_ok = min(quota_ok, avail[1]) + + # Create a CartPosition for as much items as we can + for k in range(quota_ok): + if len(i) > 3 and i[2] == 1: + # Recreating + cp = i[3].clone() + cp.expires = expiry + cp.price = price + cp.save() + else: + CartPosition.objects.create( + event=event, item=item, variation=variation, price=price, expires=expiry, + user=user if user and user.is_authenticated() else None, + session=guest_session if not user or not user.is_authenticated() else None + ) + return err + + +def add_items_to_cart(event: str, items: list, user: int=None, guest_session: str=None): + """ + Adds a list of items to a user's or a guest'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 user: User ID + :param guest_session: Session ID of a guest + :raises CartError: On any error that occured + """ + if user: + user = User.objects.get(id=user) + event = Event.objects.current.get(identity=event) + try: + with event.lock(): + _check_date(event) + existing = CartPosition.objects.current.filter(_user_cart_q(user, guest_session) & Q(event=event)).count() + if sum(i[2] 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() + timedelta(minutes=event.settings.get('reservation_time', as_type=int)) + _extend_existing(event, user, guest_session, expiry) + + expired = _re_add_expired_positions(items, event, user, guest_session) + if not items: + raise CartError(error_messages['empty']) + + err = _add_items(event, items, user, guest_session, expiry) + _delete_expired(expired) + if err: + raise CartError(err) + except EventLock.LockTimeoutException: + raise CartError(error_messages['busy']) + + +def remove_items_from_cart(event: str, items: list, user: int=None, guest_session: str=None): + """ + Removes a list of items from a user's or a guest'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 user: User ID + :param guest_session: Session ID of a guest + """ + if user: + user = User.objects.get(id=user) + event = Event.objects.current.get(identity=event) + + for item, variation, cnt in items: + cw = _user_cart_q(user, guest_session) & Q(item_id=item) & Q(event=event) + if variation: + cw &= Q(variation_id=variation) + else: + cw &= Q(variation__isnull=True) + for cp in CartPosition.objects.current.filter(cw).order_by("-price")[:cnt]: + cp.delete() diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index b157449585..df0695a28e 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -13,6 +13,9 @@ from django.views.generic import View from pretix.base.models import ( CartPosition, EventLock, Item, ItemVariation, Quota, ) +from pretix.base.services.cart import ( + CartError, add_items_to_cart, remove_items_from_cart, +) from pretix.presale.views import ( EventViewMixin, LoginOrGuestRequiredMixin, user_cart_q, ) @@ -70,52 +73,25 @@ class CartRemove(EventViewMixin, CartActionMixin, LoginOrGuestRequiredMixin, Vie items = self._items_from_post_data() if not items: return redirect(self.get_failure_url()) - qw = user_cart_q(self.request) - - for item, variation, cnt in items: - cw = qw & Q(item_id=item) - if variation: - cw &= Q(variation_id=variation) - else: - cw &= Q(variation__isnull=True) - for cp in CartPosition.objects.current.filter(cw).order_by("-price")[:cnt]: - cp.delete() + remove_items_from_cart(self.request.event.identity, items, self.request.user.od, + self.request.session.session_key) messages.success(self.request, _('Your cart has been updated.')) return redirect(self.get_success_url()) class CartAdd(EventViewMixin, CartActionMixin, View): - error_messages = { - 'unavailable': _('Some of the products you selected were no longer available. ' - 'Please see below for details.'), - 'in_part': _('Some of the products you selected were no longer available in ' - 'the quantity you selected. Please see below for details.'), - 'busy': _('We were not able to process your request completely as the ' - 'server was too busy. Please try again.'), - 'not_for_sale': _('You selected a product which is not available for sale.'), - 'max_items': _("You cannot select more than %s items per order"), - } - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.msg_some_unavailable = False def post(self, request, *args, **kwargs): - if request.event.presale_start and now() < request.event.presale_start: - messages.error(request, _('The presale period for this event has not yet started.')) - return redirect(self.get_failure_url()) - if request.event.presale_end and now() > request.event.presale_end: - messages.error(request, _('The presale period has ended.')) - return redirect(self.get_failure_url()) - - self.items = self._items_from_post_data() + items = self._items_from_post_data() # We do not use LoginRequiredMixin here, as we want to store stuff into the # session before redirecting to login if not request.user.is_authenticated() and 'guest_email' not in request.session: - request.session['cart_tmp'] = json.dumps(self.items) + request.session['cart_tmp'] = json.dumps(items) return redirect_to_login( self.get_success_url(), reverse('presale:event.checkout.login', kwargs={ 'organizer': request.event.organizer.slug, @@ -123,142 +99,14 @@ class CartAdd(EventViewMixin, CartActionMixin, View): }), 'next' ) - existing = CartPosition.objects.current.filter(user_cart_q(self.request) & Q(event=self.request.event)).count() - if sum(i[2] for i in self.items) + existing > int(self.request.event.settings.max_items_per_order): - # TODO: i18n plurals - self.error_message(self.error_messages['max_items'] % self.request.event.settings.max_items_per_order) - return redirect(self.get_failure_url()) + return self.process(items) - return self.process() - - def error_message(self, msg, important=False): - if not self.msg_some_unavailable or important: - self.msg_some_unavailable = True - messages.error(self.request, msg) - - def _re_add_position(self, position): - self.items.insert(0, (position.item_id, position.variation_id, 1, position)) - - def _re_add_expired_positions(self): - 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! - for cp in CartPosition.objects.current.filter( - user_cart_q(self.request) & Q(event=self.request.event) & Q(expires__lte=now()) - ): - self._re_add_position(cp) - positions.add(cp) - return positions - - def _extend_existing(self, expiry): - # 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.current.filter( - user_cart_q(self.request) & Q(event=self.request.event) & Q(expires__gt=now()) - ).update(expires=expiry) - - def _delete_expired(self): - for cp in self._expired: - if cp.version_end_date is None: - cp.delete() - - def _initial_checks(self): - self._expired = self._re_add_expired_positions() - - if not self.items: - return redirect(self.get_failure_url()) - - def process(self): - expiry = now() + timedelta(minutes=self.request.event.settings.get('reservation_time', as_type=int)) - self._extend_existing(expiry) - - self._initial_checks() - - # Fetch items from the database - items_cache = { - i.identity: i for i - in Item.objects.current.filter( - event=self.request.event, - identity__in=[i[0] for i in self.items] - ).prefetch_related("quotas") - } - variations_cache = { - v.identity: v for v - in ItemVariation.objects.current.filter( - item__event=self.request.event, - identity__in=[i[1] for i in self.items if i[1] is not None] - ).select_related("item", "item__event").prefetch_related("quotas", "values", "values__prop") - } + def process(self, items): try: - with self.request.event.lock(): - # Process the request itself - for i in self.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[0] not in items_cache or (i[1] is not None and i[1] not in variations_cache): - self.error_message(self.error_messages['not_for_sale']) - return redirect(self.get_failure_url()) - - item = items_cache[i[0]] - variation = variations_cache[i[1]] if i[1] is not None else None - - # Execute restriction plugins to check whether they (a) change the price or - # (b) make the item/variation unavailable. If neither is the case, check_restriction - # will correctly return the default price - price = item.check_restrictions() if variation is None else variation.check_restrictions() - - # 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 price is False or len(quotas) == 0 or not item.active: - self.error_message(self.error_messages['unavailable']) - continue - - # Assume that all quotas allow us to buy i[2] instances of the object - quota_ok = i[2] - for quota in quotas: - avail = quota.availability() - if avail[1] < i[2]: - # This quota is not available or less than i[2] items are left, so we have to - # reduce the number of bought items - self.error_message( - self.error_messages['unavailable'] - if avail[0] != Quota.AVAILABILITY_OK - else self.error_messages['in_part'] - ) - quota_ok = min(quota_ok, avail[1]) - - # Create a CartPosition for as much items as we can - for k in range(quota_ok): - if len(i) > 3 and i[2] == 1: - # Recreating - cp = i[3].clone() - cp.expires = expiry - cp.price = price - cp.save() - else: - cp = CartPosition( - event=self.request.event, - item=item, - variation=variation, - price=price, - expires=expiry - ) - if self.request.user.is_authenticated(): - cp.user = self.request.user - else: - cp.session = self.request.session.session_key - cp.save() - - self._delete_expired() - - if not self.msg_some_unavailable: - messages.success(self.request, _('The products have been successfully added to your cart.')) - + add_items_to_cart(self.request.event.identity, items, self.request.user.id, + self.request.session.session_key) + messages.success(self.request, _('The products have been successfully added to your cart.')) return redirect(self.get_success_url()) - except EventLock.LockTimeoutException: - # Is raised when there are too many threads asking for quota locks and we were - # unaible to get one - self.error_message(self.error_messages['busy'], important=True) + except CartError as e: + messages.error(self.request, str(e)) + return redirect(self.get_failure_url()) diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index bc14346111..52e7c74597 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -19,6 +19,7 @@ from pretix.base.forms.auth import ( ) from pretix.base.forms.user import UserSettingsForm from pretix.base.models import User +from pretix.base.services.cart import CartError, add_items_to_cart from pretix.base.services.mail import mail from pretix.helpers.urls import build_absolute_uri from pretix.presale.forms.checkout import GuestForm @@ -90,10 +91,12 @@ class EventLogin(EventViewMixin, TemplateView): if 'cart_tmp' in self.request.session: items = json.loads(self.request.session['cart_tmp']) del self.request.session['cart_tmp'] - ca = CartAdd() - ca.request = self.request - ca.items = items - return ca.process() + try: + add_items_to_cart(self.request.event.identity, items, self.request.user.id, + self.request.session.session_key) + messages.success(self.request, _('The products have been successfully added to your cart.')) + except CartError as e: + messages.error(self.request, str(e)) if 'next' in self.request.GET: return redirect(self.request.GET.get('next')) else: