Add new option Item.min_per_order

This commit is contained in:
Raphael Michel
2017-04-13 14:16:23 +02:00
parent ae6ad8870d
commit 3c59a870e7
7 changed files with 172 additions and 10 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -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'}),

View File

@@ -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" %}

View File

@@ -112,6 +112,13 @@
</a>
{% if item.description %}<p>{{ item.description|localize|rich_text }}</p>
{% endif %}
{% if item.min_per_order %}
<p><small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small></p>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% 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 %}
<p><small>
{% blocktrans trimmed with num=item.min_per_order %}
minimum amount to order: {{ num }}
{% endblocktrans %}
</small></p>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% if item.free_price %}

View File

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