diff --git a/src/pretix/base/migrations/0052_auto_20170324_1506.py b/src/pretix/base/migrations/0052_auto_20170324_1506.py new file mode 100644 index 000000000..489990c72 --- /dev/null +++ b/src/pretix/base/migrations/0052_auto_20170324_1506.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-24 15:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0051_auto_20170206_2027'), + ] + + operations = [ + migrations.AlterModelOptions( + name='invoice', + options={'ordering': ('invoice_no',)}, + ), + migrations.AlterModelOptions( + name='orderposition', + options={'ordering': ('positionid', 'id'), 'verbose_name': 'Order position', 'verbose_name_plural': 'Order positions'}, + ), + migrations.AddField( + model_name='item', + name='max_per_order', + field=models.IntegerField(blank=True, help_text='This product can only be bought at most this times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum times per order'), + ), + migrations.AlterField( + model_name='item', + name='allow_cancel', + field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'), + ), + migrations.AlterField( + model_name='item', + name='default_price', + field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index b94c5e2c6..c4e98e5f5 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -111,6 +111,8 @@ class Item(LoggedModel): :type hide_without_voucher: bool :param allow_cancel: If set to ``False``, an order with this product can not be canceled by the user. :type allow_cancel: bool + :param max_per_order: Maximum number of times this item can be in an order. None for unlimited. + :type max_per_order: int """ event = models.ForeignKey( @@ -203,6 +205,13 @@ class Item(LoggedModel): 'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own ' 'and you can cancel orders at all times, regardless of this setting') ) + max_per_order = models.IntegerField( + verbose_name=_('Maximum amount per order'), + null=True, blank=True, + help_text=_('This product can only be bought at most this many times within one order. If you keep the field ' + 'empty or set it to 0, there is no special limit for this product. The limit for the maximum ' + 'number of items in the whole order applies regardless.') + ) class Meta: verbose_name = _("Product") diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index bbf953e11..5ad77edfe 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -33,6 +33,7 @@ error_messages = { '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."), '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.'), @@ -131,6 +132,21 @@ class CartManager: if op.voucher and not op.voucher.applies_to(op.item, op.variation): raise CartError(error_messages['voucher_invalid_item']) + if isinstance(op, self.AddOperation): + if op.item.max_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 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}) + def _get_price(self, item: Item, variation: Optional[ItemVariation], voucher: Optional[Voucher], custom_price: Optional[Decimal]): price = item.default_price if variation is None else ( diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 81d468a77..92854fe01 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -45,6 +45,8 @@ error_messages = { 'meantime. Please see below for details.'), 'internal': _("An internal error occured, please try again."), 'empty': _("Your cart is empty."), + 'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s. We removed the " + "surplus items from your cart."), 'busy': _('We were not able to process your request completely as the ' 'server was too busy. Please try again.'), 'not_started': _('The presale period for this event has not yet started.'), @@ -206,8 +208,10 @@ def _check_date(event: Event, now_dt: datetime): def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]): err = None + errargs = None _check_date(event, now_dt) + products_seen = Counter() for i, cp in enumerate(positions): if not cp.item.active or (cp.variation and not cp.variation.active): err = err or error_messages['unavailable'] @@ -215,6 +219,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio continue quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) + products_seen[cp.item] += 1 + if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order: + err = error_messages['max_items_per_product'] + errargs = {'max': cp.item.max_per_order, + 'product': cp.item.name} + cp.delete() # Sorry! + break + if cp.voucher: redeemed_in_carts = CartPosition.objects.filter( Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt) @@ -286,7 +298,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio else: cp.delete() # Sorry! if err: - raise OrderError(err) + raise OrderError(err, errargs) def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index aaf0b0e7d..81b53d988 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -167,7 +167,8 @@ class ItemUpdateForm(I18nModelForm): 'available_until', 'require_voucher', 'hide_without_voucher', - 'allow_cancel' + 'allow_cancel', + 'max_per_order' ] widgets = { 'available_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 7a0684dc5..3c59b0483 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -25,6 +25,7 @@ {% trans "Availability" %} {% bootstrap_field form.available_from layout="horizontal" %} {% bootstrap_field form.available_until layout="horizontal" %} + {% bootstrap_field form.max_per_order layout="horizontal" %} {% bootstrap_field form.require_voucher layout="horizontal" %} {% bootstrap_field form.hide_without_voucher layout="horizontal" %} {% bootstrap_field form.allow_cancel layout="horizontal" %} diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index fba0100da..e040588f8 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -183,6 +183,8 @@ class RedeemView(EventViewMixin, TemplateView): if self.voucher.item_id and self.voucher.variation_id: item.available_variations = [v for v in item.available_variations if v.pk == self.voucher.variation_id] + item.order_max = item.max_per_order or int(self.request.event.settings.max_items_per_order) + item.has_variations = item.variations.exists() if not item.has_variations: if self.voucher.allow_ignore_quota or self.voucher.block_quota: diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 4abb7bc65..d224771d9 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -63,11 +63,12 @@ def get_grouped_items(event): display_add_to_cart = False quota_cache = {} for item in items: + max_per_order = item.max_per_order or int(event.settings.max_items_per_order) if not item.has_variations: item.cached_availability = list(item.check_quotas(_cache=quota_cache)) item.order_max = min(item.cached_availability[1] if item.cached_availability[1] is not None else sys.maxsize, - int(event.settings.max_items_per_order)) + max_per_order) item.price = item.default_price item.display_price = item.default_price_net if event.settings.display_net_prices else item.price display_add_to_cart = display_add_to_cart or item.order_max > 0 @@ -76,7 +77,7 @@ def get_grouped_items(event): var.cached_availability = list(var.check_quotas(_cache=quota_cache)) var.order_max = min(var.cached_availability[1] if var.cached_availability[1] is not None else sys.maxsize, - int(event.settings.max_items_per_order)) + max_per_order) var.display_price = var.net_price if event.settings.display_net_prices else var.price display_add_to_cart = display_add_to_cart or var.order_max > 0 if len(item.available_variations) > 0: diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index c1e76f496..e69d59b94 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -340,6 +340,36 @@ class CartTest(CartTestMixin, TestCase): self.assertIn('more than', doc.select('.alert-danger')[0].text) self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1) + def test_max_per_item_failed(self): + self.ticket.max_per_order = 2 + self.ticket.save() + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '2', + }, follow=True) + self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), + target_status_code=200) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertIn('more than', doc.select('.alert-danger')[0].text) + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1) + + def test_max_per_item_success(self): + self.ticket.max_per_order = 3 + self.ticket.save() + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '2', + }, follow=True) + self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), + target_status_code=200) + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 3) + def test_quota_full(self): self.quota_tickets.size = 0 self.quota_tickets.save() diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index d2ea7785b..a543ab956 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -553,6 +553,33 @@ class CheckoutTestCase(TestCase): self.assertEqual(Order.objects.count(), 1) self.assertEqual(OrderPosition.objects.count(), 1) + def test_max_per_item_failed(self): + self.quota_tickets.size = 3 + self.quota_tickets.save() + self.ticket.max_per_order = 1 + self.ticket.save() + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10), + ) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10), + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key).count(), 1) + self.assertEqual(len(doc.select(".alert-danger")), 1) + self.assertFalse(Order.objects.exists()) + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(OrderPosition.objects.count(), 1) + def test_confirm_expired_partial(self): self.quota_tickets.size = 1 self.quota_tickets.save()