Refactored cart actions into pretix.base.services

This commit is contained in:
Raphael Michel
2015-09-27 20:51:14 +02:00
parent 37e00b805e
commit f3e03deba4
3 changed files with 214 additions and 171 deletions

View File

@@ -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()

View File

@@ -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())

View File

@@ -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: