Repository: django-oscar/django-oscar

Issue:
Basket threshold not consistently enforced
oscar's is_quantity_allowed bases its answer on the basket's num_items before an update and the quantity in the proposed update. This is flawed because if you're updating the quantity for a line, it is double-counting the previous quantity for the line.

Steps to reproduce bug:
0) Set OSCAR_MAX_BASKET_QUANTITY_THRESHOLD in your settings to any number - say, 10.
1) add a single item to your cart.
2) update the quantity to be something less than the threshold, say 5
3) try to update the quantity to something more than threshold - current quantity - say, 6
Result:
Quantity: 6
Error message: "Due to technical limitations we are not able to ship more than 10 items in an order."

Relevant code: 

```
    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}
```

in oscar/apps/basket/abstract_models.py
![basket___flyshop_-_welcome_to_surround_video_](https://cloud.githubusercontent.com/assets/15231879/14157409/dd1edfda-f698-11e5-85b4-fd066cfef05d.png)



Discussion:
Fix:

In basket/models, make new is_line_quantity_allowed function to also take a line argument and use that to calculate the correct limit:

```
def is_line_quantity_allowed(self, qty, line):
    """
    Test whether the passed quantity of items can be added to the basket
    """
    # We enfore 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 + line.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 True, None
```

In basket/forms, change check_max_allowed_quantity to supply the line:

```
def check_max_allowed_quantity(self, qty):
    is_allowed, reason = self.instance.basket.is_line_quantity_allowed(qty, self.instance)
    if not is_allowed:
        raise forms.ValidationError(reason)
```

The calculation in do_reorder can stay as-is.

Cool, thanks. Can you supply a PR with an unittest? 


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
@@ -133,7 +133,7 @@ def is_quantity_allowed(self, qty):
         """
         Test whether the passed quantity of items can be added to the basket
         """
-        # We enfore a max threshold to prevent a DOS attack via the offers
+        # 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:
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
@@ -28,7 +28,13 @@ def clean_quantity(self):
         return qty
 
     def check_max_allowed_quantity(self, qty):
-        is_allowed, reason = self.instance.basket.is_quantity_allowed(qty)
+        # Since `Basket.is_quantity_allowed` checks quantity of added product
+        # against total number of the products in the basket, instead of sending
+        # updated quantity of the product, we send difference between current
+        # number and updated. Thus, product already in the basket and we don't
+        # add second time, just updating number of items.
+        qty_delta = qty - self.instance.quantity
+        is_allowed, reason = self.instance.basket.is_quantity_allowed(qty_delta)
         if not is_allowed:
             raise forms.ValidationError(reason)
 


Test patch:
diff --git a/tests/integration/basket/form_tests.py b/tests/integration/basket/form_tests.py
--- a/tests/integration/basket/form_tests.py
+++ b/tests/integration/basket/form_tests.py
@@ -60,11 +60,23 @@ def test_skips_availability_policy_for_zero_quantities(self):
         form = self.build_form(quantity=0)
         self.assertTrue(form.is_valid())
 
-    def test_enforces_max_line_quantity(self):
+    def test_enforces_max_line_quantity_for_new_product(self):
         invalid_qty = settings.OSCAR_MAX_BASKET_QUANTITY_THRESHOLD + 1
         form = self.build_form(quantity=invalid_qty)
         self.assertFalse(form.is_valid())
 
+    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)
+        self.line = self.basket.all_lines()[0]
+        form = self.build_form(quantity=6)
+        self.assertTrue(form.is_valid())
+        form.save()
+        form = self.build_form(quantity=11)
+        self.assertFalse(form.is_valid())
+
     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