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 000000000..78cfda7df --- /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 c4e98e5f5..20471bac4 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 f24b4af24..8bb73c223 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 81b53d988..87b61d899 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 3c59b0483..64452448c 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 86cfd9b06..44f51f838 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 %}+ {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} +
+ {% endif %}