From 3c59a870e7816fc66bed2d5115643bd63f9242e6 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 13 Apr 2017 14:16:23 +0200 Subject: [PATCH] Add new option Item.min_per_order --- .../migrations/0054_auto_20170413_1050.py | 40 ++++++++++++ src/pretix/base/models/items.py | 8 +++ src/pretix/base/services/cart.py | 64 ++++++++++++++++--- src/pretix/control/forms/item.py | 3 +- .../templates/pretixcontrol/item/index.html | 1 + .../templates/pretixpresale/event/index.html | 14 ++++ src/tests/presale/test_cart.py | 52 +++++++++++++++ 7 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 src/pretix/base/migrations/0054_auto_20170413_1050.py diff --git a/src/pretix/base/migrations/0054_auto_20170413_1050.py b/src/pretix/base/migrations/0054_auto_20170413_1050.py new file mode 100644 index 0000000000..78cfda7dfb --- /dev/null +++ b/src/pretix/base/migrations/0054_auto_20170413_1050.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-04-13 10:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0053_auto_20170409_1651'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='min_per_order', + field=models.IntegerField(blank=True, help_text='This product can only be bought if it is added to the cart at least this many times. If you keep the field empty or set it to 0, there is no special limit for this product.', null=True, verbose_name='Minimum amount per order'), + ), + migrations.AlterField( + model_name='event', + name='currency', + field=models.CharField(choices=[('AED', 'AED - UAE Dirham'), ('AFN', 'AFN - Afghani'), ('ALL', 'ALL - Lek'), ('AMD', 'AMD - Armenian Dram'), ('ANG', 'ANG - Netherlands Antillean Guilder'), ('AOA', 'AOA - Kwanza'), ('ARS', 'ARS - Argentine Peso'), ('AUD', 'AUD - Australian Dollar'), ('AWG', 'AWG - Aruban Florin'), ('AZN', 'AZN - Azerbaijanian Manat'), ('BAM', 'BAM - Convertible Mark'), ('BBD', 'BBD - Barbados Dollar'), ('BDT', 'BDT - Taka'), ('BGN', 'BGN - Bulgarian Lev'), ('BHD', 'BHD - Bahraini Dinar'), ('BIF', 'BIF - Burundi Franc'), ('BMD', 'BMD - Bermudian Dollar'), ('BND', 'BND - Brunei Dollar'), ('BOB', 'BOB - Boliviano'), ('BRL', 'BRL - Brazilian Real'), ('BSD', 'BSD - Bahamian Dollar'), ('BTN', 'BTN - Ngultrum'), ('BWP', 'BWP - Pula'), ('BYR', 'BYR - Belarusian Ruble'), ('BZD', 'BZD - Belize Dollar'), ('CAD', 'CAD - Canadian Dollar'), ('CDF', 'CDF - Congolese Franc'), ('CHF', 'CHF - Swiss Franc'), ('CLP', 'CLP - Chilean Peso'), ('CNY', 'CNY - Yuan Renminbi'), ('COP', 'COP - Colombian Peso'), ('CRC', 'CRC - Costa Rican Colon'), ('CUC', 'CUC - Peso Convertible'), ('CUP', 'CUP - Cuban Peso'), ('CVE', 'CVE - Cabo Verde Escudo'), ('CZK', 'CZK - Czech Koruna'), ('DJF', 'DJF - Djibouti Franc'), ('DKK', 'DKK - Danish Krone'), ('DOP', 'DOP - Dominican Peso'), ('DZD', 'DZD - Algerian Dinar'), ('EGP', 'EGP - Egyptian Pound'), ('ERN', 'ERN - Nakfa'), ('ETB', 'ETB - Ethiopian Birr'), ('EUR', 'EUR - Euro'), ('FJD', 'FJD - Fiji Dollar'), ('FKP', 'FKP - Falkland Islands Pound'), ('GBP', 'GBP - Pound Sterling'), ('GEL', 'GEL - Lari'), ('GHS', 'GHS - Ghana Cedi'), ('GIP', 'GIP - Gibraltar Pound'), ('GMD', 'GMD - Dalasi'), ('GNF', 'GNF - Guinea Franc'), ('GTQ', 'GTQ - Quetzal'), ('GYD', 'GYD - Guyana Dollar'), ('HKD', 'HKD - Hong Kong Dollar'), ('HNL', 'HNL - Lempira'), ('HRK', 'HRK - Kuna'), ('HTG', 'HTG - Gourde'), ('HUF', 'HUF - Forint'), ('IDR', 'IDR - Rupiah'), ('ILS', 'ILS - New Israeli Sheqel'), ('INR', 'INR - Indian Rupee'), ('IQD', 'IQD - Iraqi Dinar'), ('IRR', 'IRR - Iranian Rial'), ('ISK', 'ISK - Iceland Krona'), ('JMD', 'JMD - Jamaican Dollar'), ('JOD', 'JOD - Jordanian Dinar'), ('JPY', 'JPY - Yen'), ('KES', 'KES - Kenyan Shilling'), ('KGS', 'KGS - Som'), ('KHR', 'KHR - Riel'), ('KMF', 'KMF - Comoro Franc'), ('KPW', 'KPW - North Korean Won'), ('KRW', 'KRW - Won'), ('KWD', 'KWD - Kuwaiti Dinar'), ('KYD', 'KYD - Cayman Islands Dollar'), ('KZT', 'KZT - Tenge'), ('LAK', 'LAK - Kip'), ('LBP', 'LBP - Lebanese Pound'), ('LKR', 'LKR - Sri Lanka Rupee'), ('LRD', 'LRD - Liberian Dollar'), ('LSL', 'LSL - Loti'), ('LYD', 'LYD - Libyan Dinar'), ('MAD', 'MAD - Moroccan Dirham'), ('MDL', 'MDL - Moldovan Leu'), ('MGA', 'MGA - Malagasy Ariary'), ('MKD', 'MKD - Denar'), ('MMK', 'MMK - Kyat'), ('MNT', 'MNT - Tugrik'), ('MOP', 'MOP - Pataca'), ('MRO', 'MRO - Ouguiya'), ('MUR', 'MUR - Mauritius Rupee'), ('MVR', 'MVR - Rufiyaa'), ('MWK', 'MWK - Malawi Kwacha'), ('MXN', 'MXN - Mexican Peso'), ('MYR', 'MYR - Malaysian Ringgit'), ('MZN', 'MZN - Mozambique Metical'), ('NAD', 'NAD - Namibia Dollar'), ('NGN', 'NGN - Naira'), ('NIO', 'NIO - Cordoba Oro'), ('NOK', 'NOK - Norwegian Krone'), ('NPR', 'NPR - Nepalese Rupee'), ('NZD', 'NZD - New Zealand Dollar'), ('OMR', 'OMR - Rial Omani'), ('PAB', 'PAB - Balboa'), ('PEN', 'PEN - Sol'), ('PGK', 'PGK - Kina'), ('PHP', 'PHP - Philippine Peso'), ('PKR', 'PKR - Pakistan Rupee'), ('PLN', 'PLN - Zloty'), ('PYG', 'PYG - Guarani'), ('QAR', 'QAR - Qatari Rial'), ('RON', 'RON - Romanian Leu'), ('RSD', 'RSD - Serbian Dinar'), ('RUB', 'RUB - Russian Ruble'), ('RWF', 'RWF - Rwanda Franc'), ('SAR', 'SAR - Saudi Riyal'), ('SBD', 'SBD - Solomon Islands Dollar'), ('SCR', 'SCR - Seychelles Rupee'), ('SDG', 'SDG - Sudanese Pound'), ('SEK', 'SEK - Swedish Krona'), ('SGD', 'SGD - Singapore Dollar'), ('SHP', 'SHP - Saint Helena Pound'), ('SLL', 'SLL - Leone'), ('SOS', 'SOS - Somali Shilling'), ('SRD', 'SRD - Surinam Dollar'), ('SSP', 'SSP - South Sudanese Pound'), ('STD', 'STD - Dobra'), ('SVC', 'SVC - El Salvador Colon'), ('SYP', 'SYP - Syrian Pound'), ('SZL', 'SZL - Lilangeni'), ('THB', 'THB - Baht'), ('TJS', 'TJS - Somoni'), ('TMT', 'TMT - Turkmenistan New Manat'), ('TND', 'TND - Tunisian Dinar'), ('TOP', 'TOP - Pa’anga'), ('TRY', 'TRY - Turkish Lira'), ('TTD', 'TTD - Trinidad and Tobago Dollar'), ('TWD', 'TWD - New Taiwan Dollar'), ('TZS', 'TZS - Tanzanian Shilling'), ('UAH', 'UAH - Hryvnia'), ('UGX', 'UGX - Uganda Shilling'), ('USD', 'USD - US Dollar'), ('UYU', 'UYU - Peso Uruguayo'), ('UZS', 'UZS - Uzbekistan Sum'), ('VEF', 'VEF - Bolívar'), ('VND', 'VND - Dong'), ('VUV', 'VUV - Vatu'), ('WST', 'WST - Tala'), ('XAF', 'XAF - CFA Franc BEAC'), ('XAG', 'XAG - Silver'), ('XAU', 'XAU - Gold'), ('XBA', 'XBA - Bond Markets Unit European Composite Unit (EURCO)'), ('XBB', 'XBB - Bond Markets Unit European Monetary Unit (E.M.U.-6)'), ('XBC', 'XBC - Bond Markets Unit European Unit of Account 9 (E.U.A.-9)'), ('XBD', 'XBD - Bond Markets Unit European Unit of Account 17 (E.U.A.-17)'), ('XCD', 'XCD - East Caribbean Dollar'), ('XDR', 'XDR - SDR (Special Drawing Right)'), ('XOF', 'XOF - CFA Franc BCEAO'), ('XPD', 'XPD - Palladium'), ('XPF', 'XPF - CFP Franc'), ('XPT', 'XPT - Platinum'), ('XSU', 'XSU - Sucre'), ('XTS', 'XTS - Codes specifically reserved for testing purposes'), ('XUA', 'XUA - ADB Unit of Account'), ('XXX', 'XXX - The codes assigned for transactions where no currency is involved'), ('YER', 'YER - Yemeni Rial'), ('ZAR', 'ZAR - Rand'), ('ZMW', 'ZMW - Zambian Kwacha'), ('ZWL', 'ZWL - Zimbabwe Dollar')], default='EUR', max_length=10, verbose_name='Default currency'), + ), + migrations.AlterField( + model_name='event_settingsstore', + name='key', + field=models.CharField(db_index=True, max_length=255), + ), + migrations.AlterField( + model_name='item', + name='max_per_order', + field=models.IntegerField(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.', null=True, verbose_name='Maximum amount per order'), + ), + migrations.AlterField( + model_name='organizer_settingsstore', + name='key', + field=models.CharField(db_index=True, max_length=255), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index c4e98e5f5e..20471bac4e 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -113,6 +113,8 @@ class Item(LoggedModel): :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 + :param min_per_order: Minimum number of times this item needs to be in an order if bought at all. None for unlimited. + :type min_per_order: int """ event = models.ForeignKey( @@ -205,6 +207,12 @@ 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') ) + min_per_order = models.IntegerField( + verbose_name=_('Minimum amount per order'), + null=True, blank=True, + help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep ' + 'the field empty or set it to 0, there is no special limit for this product.') + ) max_per_order = models.IntegerField( verbose_name=_('Maximum amount per order'), null=True, blank=True, diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index f24b4af245..8bb73c223a 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -34,6 +34,9 @@ error_messages = { '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."), + 'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."), + 'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than " + "%(min)s items of it."), '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.'), @@ -74,7 +77,7 @@ class CartManager: def positions(self): return CartPosition.objects.filter( Q(cart_id=self.cart_id) & Q(event=self.event) - ) + ).select_related('item') def _calculate_expiry(self): self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) @@ -133,7 +136,7 @@ class CartManager: raise CartError(error_messages['voucher_invalid_item']) if isinstance(op, self.AddOperation): - if op.item.max_per_order: + if op.item.max_per_order or op.item.min_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 @@ -143,13 +146,21 @@ class CartManager: 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 - } - ) + if op.item.max_per_order and 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 + } + ) + + if op.item.min_per_order and new_total < op.item.min_per_order: + raise CartError( + _(error_messages['min_items_per_product']) % { + 'min': op.item.min_per_order, + 'product': op.item.name + } + ) def _get_price(self, item: Item, variation: Optional[ItemVariation], voucher: Optional[Voucher], custom_price: Optional[Decimal]): @@ -298,12 +309,47 @@ class CartManager: return vouchers_ok + def _check_min_per_product(self): + per_product = Counter() + min_per_product = {} + for p in self.positions: + per_product[p.item_id] += 1 + min_per_product[p.item.pk] = p.item.min_per_order + + for op in self._operations: + if isinstance(op, self.AddOperation): + per_product[op.item.pk] += op.count + min_per_product[op.item.pk] = op.item.min_per_order + elif isinstance(op, self.RemoveOperation): + per_product[op.position.item_id] -= 1 + min_per_product[op.position.item.pk] = op.position.item.min_per_order + + err = None + for itemid, num in per_product.items(): + min_p = min_per_product[itemid] + if min_p and num < min_p: + self._operations = [o for o in self._operations if not ( + isinstance(o, self.AddOperation) and o.item.pk == itemid + )] + removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)] + for p in self.positions: + if p.item_id == itemid and p.pk not in removals: + self._operations.append(self.RemoveOperation(position=p)) + err = _(error_messages['min_items_per_product_removed']) % { + 'min': min_p, + 'product': p.item.name + } + + return err + def _perform_operations(self): vouchers_ok = self._get_voucher_availability() quotas_ok = self._get_quota_availability() err = None new_cart_positions = [] + err = err or self._check_min_per_product() + self._operations.sort(key=lambda a: self.order[type(a)]) for op in self._operations: diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 81b53d988b..87b61d899f 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -168,7 +168,8 @@ class ItemUpdateForm(I18nModelForm): 'require_voucher', 'hide_without_voucher', 'allow_cancel', - 'max_per_order' + 'max_per_order', + 'min_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 3c59b0483b..64452448c0 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -26,6 +26,7 @@ {% 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.min_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/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 86cfd9b066..44f51f8382 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -112,6 +112,13 @@ {% if item.description %}

{{ item.description|localize|rich_text }}

{% endif %} + {% if item.min_per_order %} +

+ {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} +

+ {% endif %}
{% if item.min_price != item.max_price or item.free_price %} @@ -207,6 +214,13 @@ {% if event.settings.show_quota_left %} {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} {% endif %} + {% if item.min_per_order %} +

+ {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} +

+ {% endif %}
{% if item.free_price %} diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index e69d59b94f..5a9f5afb99 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -370,6 +370,40 @@ class CartTest(CartTestMixin, TestCase): target_status_code=200) self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 3) + def test_min_per_item_failed(self): + self.quota_tickets.size = 30 + self.quota_tickets.save() + self.event.settings.max_items_per_order = 20 + self.ticket.min_per_order = 10 + self.ticket.save() + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '4', + }, 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('at least', doc.select('.alert-danger')[0].text) + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0) + + def test_min_per_item_success(self): + self.quota_tickets.size = 30 + self.quota_tickets.save() + self.event.settings.max_items_per_order = 20 + self.ticket.min_per_order = 10 + self.ticket.save() + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '10', + }, 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(), 10) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '3', + }, 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(), 13) + def test_quota_full(self): self.quota_tickets.size = 0 self.quota_tickets.save() @@ -472,6 +506,24 @@ class CartTest(CartTestMixin, TestCase): self.assertIn('empty', doc.select('.alert-success')[0].text) self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists()) + def test_remove_min(self): + self.ticket.min_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) + ) + 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/remove' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + }, follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertIn('less than', doc.select('.alert-danger')[0].text) + self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists()) + def test_remove_variation(self): CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_red,