diff --git a/src/pretix/plugins/testdummy/signals.py b/src/pretix/plugins/testdummy/signals.py index 3e8248e4f9..a1301a2c41 100644 --- a/src/pretix/plugins/testdummy/signals.py +++ b/src/pretix/plugins/testdummy/signals.py @@ -6,4 +6,10 @@ from pretix.base.signals import determine_availability @receiver(determine_availability) def availability_handler(sender, **kwargs): kwargs['sender'] = sender - return kwargs + variations = kwargs['variations'] + if sender.settings.testdummy_available is not None: + variations = [d.copy() for d in variations] + for v in variations: + v['available'] = (sender.settings.testdummy_available == 'yes') + return variations + return [] diff --git a/src/pretix/presale/tests/test_cart.py b/src/pretix/presale/tests/test_cart.py index cb8f2eb03c..3feb23423e 100644 --- a/src/pretix/presale/tests/test_cart.py +++ b/src/pretix/presale/tests/test_cart.py @@ -2,6 +2,8 @@ import datetime import time from bs4 import BeautifulSoup from django.test import TestCase +from django.utils.timezone import now +from datetime import timedelta from pretix.base.models import Item, Organizer, Event, ItemCategory, Quota, Property, PropertyValue, ItemVariation, User, \ CartPosition @@ -219,23 +221,27 @@ class CartTest(CartTestMixin, TestCase): self.assertIn('no longer available', doc.select('.alert-danger')[0].text) self.assertFalse(CartPosition.objects.filter(user=self.user, event=self.event).exists()) - def test_quota_max_items(self): + def test_max_items(self): + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) self.event.settings.max_items_per_order = 5 response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'item_' + self.ticket.identity: '6', - }, follow=True) + 'item_' + self.ticket.identity: '5', + }, follow=True) self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), target_status_code=200) doc = BeautifulSoup(response.rendered_content) self.assertIn('more than', doc.select('.alert-danger')[0].text) - self.assertFalse(CartPosition.objects.filter(user=self.user, event=self.event).exists()) + self.assertEqual(CartPosition.objects.filter(user=self.user, event=self.event).count(), 1) def test_quota_full(self): self.quota_tickets.size = 0 self.quota_tickets.save() response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 'item_' + self.ticket.identity: '1', - }, follow=True) + }, follow=True) self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), target_status_code=200) doc = BeautifulSoup(response.rendered_content) @@ -246,8 +252,8 @@ class CartTest(CartTestMixin, TestCase): self.quota_tickets.size = 1 self.quota_tickets.save() response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'item_' + self.ticket.identity: '2', - }, follow=True) + 'item_' + self.ticket.identity: '2' + }, follow=True) self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), target_status_code=200) doc = BeautifulSoup(response.rendered_content) @@ -261,3 +267,144 @@ class CartTest(CartTestMixin, TestCase): self.assertEqual(objs[0].item, self.ticket) self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, 23) + + def test_renew_in_time(self): + cp = CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + }, follow=True) + cp = CartPosition.objects.current.get(identity=cp.identity) + self.assertGreater(cp.expires, now()) + + def test_renew_expired_successfully(self): + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + }, follow=True) + objs = list(CartPosition.objects.current.filter(user=self.user, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + self.assertGreater(objs[0].expires, now()) + + def test_renew_expired_failed(self): + self.quota_tickets.size = 0 + self.quota_tickets.save() + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + }, follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('no longer available', doc.select('.alert-danger')[0].text) + self.assertFalse(CartPosition.objects.current.filter(user=self.user, event=self.event).exists()) + + def test_restriction_failed(self): + self.event.plugins = 'pretix.plugins.testdummy' + self.event.save() + self.event.settings.testdummy_available = 'yes' + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '1', + }, follow=True) + self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), + target_status_code=200) + objs = list(CartPosition.objects.current.filter(user=self.user, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + + def test_restriction_ok(self): + self.event.plugins = 'pretix.plugins.testdummy' + self.event.save() + self.event.settings.testdummy_available = 'no' + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '1', + }, follow=True) + self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), + target_status_code=200) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('no longer available', doc.select('.alert-danger')[0].text) + self.assertFalse(CartPosition.objects.filter(user=self.user, event=self.event).exists()) + + def test_remove_simple(self): + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '1', + }, follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('updated', doc.select('.alert-success')[0].text) + self.assertFalse(CartPosition.objects.current.filter(user=self.user, event=self.event).exists()) + + def test_remove_variation(self): + CartPosition.objects.create( + event=self.event, user=self.user, item=self.shirt, variation=self.shirt_red, + price=14, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), { + 'variation_' + self.shirt.identity + '_' + self.shirt_red.identity: '1', + }, follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('updated', doc.select('.alert-success')[0].text) + self.assertFalse(CartPosition.objects.current.filter(user=self.user, event=self.event).exists()) + + def test_remove_one_of_multiple(self): + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '1', + }, follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('updated', doc.select('.alert-success')[0].text) + self.assertEqual(CartPosition.objects.current.filter(user=self.user, event=self.event).count(), 1) + + def test_remove_multiple(self): + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '2', + }, follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('updated', doc.select('.alert-success')[0].text) + self.assertFalse(CartPosition.objects.current.filter(user=self.user, event=self.event).exists()) + + def test_remove_most_expensive(self): + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + CartPosition.objects.create( + event=self.event, user=self.user, item=self.ticket, + price=20, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '1', + }, follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('updated', doc.select('.alert-success')[0].text) + objs = list(CartPosition.objects.current.filter(user=self.user, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 20) diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 48f7a8e673..0052d75220 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -47,16 +47,16 @@ class CartActionMixin: items.append((key.split("_")[1], None, int(value))) except ValueError: messages.error(self.request, _('Please enter numbers only.')) - return False + return [] elif key.startswith('variation_'): try: items.append((key.split("_")[1], key.split("_")[2], int(value))) except ValueError: messages.error(self.request, _('Please enter numbers only.')) - return False + return [] if len(items) == 0: messages.warning(self.request, _('You did not select any items.')) - return False + return [] return items def _re_add_position(self, items, position): @@ -91,6 +91,21 @@ class CartRemove(EventViewMixin, CartActionMixin, EventLoginRequiredMixin, View) class CartAdd(EventViewMixin, CartActionMixin, View): + error_messages = { + 'unavailable': _('Some of the items you selected were no longer available. ' + 'Please see below for details.'), + 'in_part': _('Some of the items 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 an item 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): items = self._items_from_post_data() @@ -107,20 +122,15 @@ class CartAdd(EventViewMixin, CartActionMixin, View): ) return self.process(items) - def process(self, items): - if not items: - return redirect(self.get_failure_url()) - existing = CartPosition.objects.current.filter(user=self.request.user, event=self.request.event).count() - if sum(i[2] for i in items) + existing > int(self.request.event.settings.max_items_per_order): - # TODO: i18n plurals - messages.error(self.request, - _("You cannot select more than %s items per order") % self.request.event.settings.max_items_per_order) - return redirect(self.get_failure_url()) + 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 process(self, items): # Extend this user's cart session to 30 minutes from now to ensure all items in the - # cart expire at the same - # We can extend the reservation of items which are not yet expired without - # risk + # cart expire at the same time + # We can extend the reservation of items which are not yet expired without risk CartPosition.objects.current.filter( Q(user=self.request.user) & Q(event=self.request.event) & Q(expires__gt=now()) ).update(expires=now() + timedelta(minutes=30)) @@ -132,6 +142,15 @@ class CartAdd(EventViewMixin, CartActionMixin, View): items = self._re_add_position(items, cp) cp.delete() + if not items: + return redirect(self.get_failure_url()) + + existing = CartPosition.objects.current.filter(user=self.request.user, event=self.request.event).count() + if sum(i[2] for i in 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()) + # Fetch items from the database items_cache = { i.identity: i for i @@ -149,13 +168,12 @@ class CartAdd(EventViewMixin, CartActionMixin, View): } # Process the request itself - msg_some_unavailable = False 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): - messages.error(self.request, _('You selected an item which is not available for sale.')) + self.error_message(self.error_messages['not_for_sale']) return redirect(self.get_failure_url()) item = items_cache[i[0]] @@ -166,21 +184,13 @@ class CartAdd(EventViewMixin, CartActionMixin, View): # will correctly return the default price price = item.check_restrictions() if variation is None else variation.check_restrictions() if price is False: - if not msg_some_unavailable: - msg_some_unavailable = True - messages.error(self.request, - _('Some of the items you selected were no longer available. ' - 'Please see below for details.')) + self.error_message(self.error_messages['unavailable']) continue # 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 len(quotas) == 0: - if not msg_some_unavailable: - msg_some_unavailable = True - messages.error(self.request, - _('Some of the items you selected were no longer available. ' - 'Please see below for details.')) + self.error_message(self.error_messages['unavailable']) continue # Assume that all quotas allow us to buy i[2] instances of the object @@ -193,21 +203,13 @@ class CartAdd(EventViewMixin, CartActionMixin, View): avail = quota.availability() if avail[0] != Quota.AVAILABILITY_OK: # This quota is sold out/currently unavailable, so do not sell this at all - if not msg_some_unavailable: - msg_some_unavailable = True - messages.error(self.request, - _('Some of the items you selected were no longer available. ' - 'Please see below for details.')) + self.error_message(self.error_messages['unavailable']) quota_ok = 0 break elif avail[1] < i[2]: # This quota is available, but with less than i[2] items left, so we have to # reduce the number of bought items - if not msg_some_unavailable: - msg_some_unavailable = True - messages.error(self.request, - _('Some of the items you selected were no longer available in ' - 'the quantity you selected. Please see below for details.')) + self.error_message(self.error_messages['in_part']) quota_ok = min(quota_ok, avail[1]) # Create a CartPosition for as much items as we can @@ -223,20 +225,13 @@ class CartAdd(EventViewMixin, CartActionMixin, View): except Quota.LockTimeoutException: # Is raised when there are too many threads asking for quota locks and we were # unaible to get one - if not msg_some_unavailable: - msg_some_unavailable = True - messages.error(self.request, - _('We were not able to process your request completely as the ' - 'server was too busy. Please try again.')) + self.error_message(self.error_messages['busy'], important=True) finally: # Release the locks. This is important ;) for quota in quotas: quota.release() - if not msg_some_unavailable: + if not self.msg_some_unavailable: messages.success(self.request, _('The items have been successfully added to your cart.')) return redirect(self.get_success_url()) - - def get(self, request, *args, **kwargs): - return redirect(self.get_failure_url())