Repository: django-oscar/django-oscar

Issue:
Set maximum quantity on basket page
Extracted from #1405, we should try and set the `max` attribute on the basket line form. I think this will require a new method on the availability policy class to fetch the maximum permitted quantity.



Discussion:
If the max quantity is 1 maybe the quantity field can be hidden right away.

Yeah why not :+1: 

I have personally solved it adding max_allowed_purchace to my avalability strategy. if return is not false, its an int, which is the max available purchace. My add to basket form catches that up in the **init** as David suggested in the group mail.

BUT Im currently working on a new approach though. 
I feel its better - no I actually feel its the best I ever seen so far! :)
Im using the qty field for updating the qty, which means initial value is 0 or the current qty in basket.
I found its actually very handy for solving many of the challenges I had with giving my customers simple and relevant information about their possibilities, exp delivery time, max purchace possible, current qty etc.
Im almost done with my first version of it. its not beautiful, but I think I nailed something... It really feels good to use it and I think I will shift to this approach
next version I will try to build it so it updates the basket via ajax
Wanna see it when im ready, just write me here

Is anyone working on this?

I'm not aware of anyone :)

I'll pick this one up. It's a common requirement for us and we've implemented it multiple times already :-)


Resolving patch:
diff --git a/src/oscar/apps/basket/abstract_models.py b/src/oscar/apps/basket/abstract_models.py
--- a/src/oscar/apps/basket/abstract_models.py
+++ b/src/oscar/apps/basket/abstract_models.py
@@ -135,21 +135,29 @@ def all_lines(self):
                 .order_by(self._meta.pk.name))
         return self._lines
 
-    def is_quantity_allowed(self, qty):
+    def max_allowed_quantity(self):
         """
-        Test whether the passed quantity of items can be added to the basket
+        Returns maximum product quantity, that can be added to the basket
+        with the respect to basket quantity threshold.
         """
-        # We enforce a max threshold to prevent a DOS attack via the offers
-        # system.
         basket_threshold = settings.OSCAR_MAX_BASKET_QUANTITY_THRESHOLD
         if basket_threshold:
             total_basket_quantity = self.num_items
             max_allowed = basket_threshold - total_basket_quantity
-            if qty > max_allowed:
-                return False, _(
-                    "Due to technical limitations we are not able "
-                    "to ship more than %(threshold)d items in one order.") \
-                    % {'threshold': basket_threshold}
+            return max_allowed, basket_threshold
+
+    def is_quantity_allowed(self, qty):
+        """
+        Test whether the passed quantity of items can be added to the basket
+        """
+        # We enforce a max threshold to prevent a DOS attack via the offers
+        # system.
+        max_allowed, basket_threshold = self.max_allowed_quantity()
+        if max_allowed is not None and qty > max_allowed:
+            return False, _(
+                "Due to technical limitations we are not able "
+                "to ship more than %(threshold)d items in one order.") \
+                % {'threshold': basket_threshold}
         return True, None
 
     # ============
diff --git a/src/oscar/apps/basket/forms.py b/src/oscar/apps/basket/forms.py
--- a/src/oscar/apps/basket/forms.py
+++ b/src/oscar/apps/basket/forms.py
@@ -19,6 +19,16 @@ def __init__(self, strategy, *args, **kwargs):
         super(BasketLineForm, self).__init__(*args, **kwargs)
         self.instance.strategy = strategy
 
+        max_allowed_quantity = None
+        num_available = getattr(self.instance.purchase_info.availability, 'num_available', None)
+        basket_max_allowed_quantity = self.instance.basket.max_allowed_quantity()[0]
+        if all([num_available, basket_max_allowed_quantity]):
+            max_allowed_quantity = min(num_available, basket_max_allowed_quantity)
+        else:
+            max_allowed_quantity = num_available or basket_max_allowed_quantity
+        if max_allowed_quantity:
+            self.fields['quantity'].widget.attrs['max'] = max_allowed_quantity
+
     def clean_quantity(self):
         qty = self.cleaned_data['quantity']
         if qty > 0:


Test patch:
diff --git a/tests/integration/basket/test_forms.py b/tests/integration/basket/test_forms.py
--- a/tests/integration/basket/test_forms.py
+++ b/tests/integration/basket/test_forms.py
@@ -1,6 +1,6 @@
 from decimal import Decimal as D
 
-from django.test import TestCase
+from django.test import TestCase, override_settings
 from django.conf import settings
 import mock
 
@@ -65,8 +65,8 @@ def test_enforces_max_line_quantity_for_new_product(self):
         form = self.build_form(quantity=invalid_qty)
         self.assertFalse(form.is_valid())
 
+    @override_settings(OSCAR_MAX_BASKET_QUANTITY_THRESHOLD=10)
     def test_enforce_max_line_quantity_for_existing_product(self):
-        settings.OSCAR_MAX_BASKET_QUANTITY_THRESHOLD = 10
         self.basket.flush()
         product = factories.create_product(num_in_stock=20)
         add_product(self.basket, D('100'), 4, product)
@@ -77,6 +77,23 @@ def test_enforce_max_line_quantity_for_existing_product(self):
         form = self.build_form(quantity=11)
         self.assertFalse(form.is_valid())
 
+    def test_line_quantity_max_attribute_per_num_available(self):
+        self.basket.flush()
+        product = factories.create_product(num_in_stock=20)
+        add_product(self.basket, D('100'), 4, product)
+        self.line = self.basket.all_lines()[0]
+        form = self.build_form()
+        self.assertIn('max="20"', str(form['quantity']))
+
+    @override_settings(OSCAR_MAX_BASKET_QUANTITY_THRESHOLD=10)
+    def test_line_quantity_max_attribute_per_basket_threshold(self):
+        self.basket.flush()
+        product = factories.create_product(num_in_stock=20)
+        add_product(self.basket, D('100'), 4, product)
+        self.line = self.basket.all_lines()[0]
+        form = self.build_form()
+        self.assertIn('max="6"', str(form['quantity']))
+
     def test_basketline_formset_ordering(self):
         # when we use a unordered queryset in the Basketlineformset, the
         # discounts will be lost because django will query the database
diff --git a/tests/integration/basket/test_models.py b/tests/integration/basket/test_models.py
--- a/tests/integration/basket/test_models.py
+++ b/tests/integration/basket/test_models.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from decimal import Decimal as D
 
-from django.test import TestCase
+from django.test import TestCase, override_settings
 
 from oscar.apps.basket.models import Basket
 from oscar.apps.catalogue.models import Option
@@ -242,6 +242,16 @@ def pricing_policy(self, product, stockrecord):
         self.assertEqual(self.basket.total_excl_tax_excl_discounts, 100)
         self.assertEqual(self.basket.total_incl_tax_excl_discounts, 100)
 
+    @override_settings(OSCAR_MAX_BASKET_QUANTITY_THRESHOLD=20)
+    def test_max_allowed_quantity(self):
+        self.basket.add_product(self.product, quantity=3)
+        self.assertEquals(self.basket.max_allowed_quantity()[0], 7)
+
+    @override_settings(OSCAR_MAX_BASKET_QUANTITY_THRESHOLD=20)
+    def test_is_quantity_allowed(self):
+        self.assertTrue(self.basket.is_quantity_allowed(qty=3)[0])
+        self.assertFalse(self.basket.is_quantity_allowed(qty=11)[0])
+
 
 class TestMergingTwoBaskets(TestCase):