Repository: django-oscar/django-oscar

Issue:
Regression: price_excl_tax is necessary for availability
I get to upgrade an old Oscar project, and that went really well. Thanks for keeping Oscar so backwards-compatible! I noticed just a small regression for a corner case:

I work on a project where it was essential that Oscar stores the tax-inclusive price and the price without tax is calculated. The reasoning is that the tax-inclusive prices are legally binding, and doing it this way ensures that a bug in the price calculation logic has less of an impact.

This was easily done by storing the price_incl_tax on the stock record, and writing a short pricing policy for it. But two recent changes [here](https://github.com/django-oscar/django-oscar/commit/82d9e7d2647ab50aacd653d21f93f8423f974fbf#diff-38aa6a43fa33c6bad17543acc1e24541) and [here](https://github.com/django-oscar/django-oscar/commit/3e4dda9a89a51323ea50d216bd895101aa014bbf#diff-38aa6a43fa33c6bad17543acc1e24541) make this harder, because they look at a `price_excl_tax` and assume because that is not set, the product is not available to buy. 

I'd like to argue that pricing and availability are concepts that should be kept separate, and the two commits linked above essentially bypass Oscar's pricing logic. 

My workaround was to backport the `StockRequired` policy from Oscar 1.4.


Discussion:
@maikhoepfel I think you're right that pricing and availability should be kept separate. 

The issue that those commits were trying to fix was an exception if you try to add a product without `price_excl_tax` to the basket. How do you handle that issue in your case?
Hey,

For me it’s not a problem, because all products have a tax-inclusive price
set, and every time Oscar wants to know about the price of a product, the
pricing strategy is queried and hence Oscar has the price it needs.

I presume you mean products without a price at all. Is Oscar even prepared
to handle that? If not, it’s a matter of making sure forms enforce a price,
and dropping out with a more helpful exception if the product was created
manually.
If Oscar should be able to handle products without a price, I’d return a
Unavailable() class in the pricing policy like we do for shipping, and make
sure all parts of Oscar can deal with it.

But that’s just my opinion - I haven’t digged into Oscar for a while.


Samir Shah <notifications@github.com> schrieb am Do. 5. Apr. 2018 um 06:55:

> @maikhoepfel <https://github.com/maikhoepfel> I think you're right that
> pricing and availability should be kept separate.
>
> The issue that those commits were trying to fix was an exception if you
> try to add a product without price_excl_tax to the basket. How do you
> handle that issue in your case?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/django-oscar/django-oscar/issues/2664#issuecomment-378820082>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAfF81ObjubBbfulRf3IBbl9D0tL7VANks5tlaPKgaJpZM4TFP7f>
> .
>


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
@@ -205,6 +205,11 @@ def add_product(self, product, quantity=1, options=None):
         # Ensure that all lines are the same currency
         price_currency = self.currency
         stock_info = self.get_stock_info(product, options)
+
+        if not stock_info.price.exists:
+            raise ValueError(
+                "Strategy hasn't found a price for product %s" % product)
+
         if price_currency and stock_info.price.currency != price_currency:
             raise ValueError((
                 "Basket lines must all have the same currency. Proposed "
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
@@ -212,6 +212,12 @@ def product(self):
     def clean(self):
         info = self.basket.strategy.fetch_for_product(self.product)
 
+        # Check that a price was found by the strategy
+        if not info.price.exists:
+            raise forms.ValidationError(
+                _("This product cannot be added to the basket because a "
+                  "price could not be determined for it."))
+
         # Check currencies are sensible
         if (self.basket.currency and
                 info.price.currency != self.basket.currency):
diff --git a/src/oscar/apps/partner/strategy.py b/src/oscar/apps/partner/strategy.py
--- a/src/oscar/apps/partner/strategy.py
+++ b/src/oscar/apps/partner/strategy.py
@@ -209,7 +209,7 @@ class StockRequired(object):
     """
 
     def availability_policy(self, product, stockrecord):
-        if not stockrecord or stockrecord.price_excl_tax is None:
+        if not stockrecord:
             return Unavailable()
         if not product.get_product_class().track_stock:
             return Available()


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
@@ -193,13 +193,16 @@ def test_mixed_currency_baskets_are_not_permitted(self):
             basket=basket, product=product, data=data)
         self.assertFalse(form.is_valid())
 
-    def test_for_empty_price_excl_tax(self):
+    def test_cannot_add_a_product_without_price(self):
         basket = factories.BasketFactory()
-        product_class = factories.ProductClassFactory(track_stock=False)
-        product = factories.ProductFactory(product_class=product_class, stockrecords=[])
-        factories.StockRecordFactory.build(price_excl_tax=None, product=product)
+        product = factories.create_product(price=None)
 
         data = {'quantity': 1}
-        form = forms.AddToBasketForm(basket=basket, product=product, data=data)
+        form = forms.AddToBasketForm(
+            basket=basket, product=product, data=data)
         self.assertFalse(form.is_valid())
-        self.assertEqual(form.errors['__all__'][0], 'unavailable')
+        self.assertEqual(
+            form.errors['__all__'][0],
+            'This product cannot be added to the basket because a price '
+            'could not be determined for it.',
+        )
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
@@ -140,6 +140,11 @@ def test_means_another_currency_product_cannot_be_added(self):
         with self.assertRaises(ValueError):
             self.basket.add(product)
 
+    def test_cannot_add_a_product_without_price(self):
+        product = factories.create_product(price=None)
+        with self.assertRaises(ValueError):
+            self.basket.add(product)
+
 
 class TestANonEmptyBasket(TestCase):
 
diff --git a/tests/integration/partner/test_strategy.py b/tests/integration/partner/test_strategy.py
--- a/tests/integration/partner/test_strategy.py
+++ b/tests/integration/partner/test_strategy.py
@@ -7,17 +7,7 @@
 from oscar.apps.basket.models import Line
 
 
-class ProductWithEmptyPriceTestMixin(object):
-
-    def test_product_with_empty_price(self):
-        product_class = factories.ProductClassFactory(track_stock=False)
-        product = factories.ProductFactory(product_class=product_class, stockrecords=[])
-        factories.StockRecordFactory(price_excl_tax=None, product=product)
-        info = self.strategy.fetch_for_product(product)
-        self.assertFalse(info.availability.is_available_to_buy)
-
-
-class TestDefaultStrategy(ProductWithEmptyPriceTestMixin, TestCase):
+class TestDefaultStrategy(TestCase):
 
     def setUp(self):
         self.strategy = strategy.Default()
@@ -57,12 +47,14 @@ def test_free_product_is_available_to_buy(self):
         self.assertTrue(info.availability.is_available_to_buy)
         self.assertTrue(info.price.exists)
 
-    def test_product_with_empty_price(self):
+    def test_availability_does_not_require_price(self):
+        # regression test for https://github.com/django-oscar/django-oscar/issues/2664
+        # The availability policy should be independent of price.
         product_class = factories.ProductClassFactory(track_stock=False)
         product = factories.ProductFactory(product_class=product_class, stockrecords=[])
         factories.StockRecordFactory(price_excl_tax=None, product=product)
         info = self.strategy.fetch_for_product(product)
-        self.assertFalse(info.availability.is_available_to_buy)
+        self.assertTrue(info.availability.is_available_to_buy)
 
 
 class TestDefaultStrategyForParentProductWhoseVariantsHaveNoStockRecords(TestCase):
@@ -126,13 +118,19 @@ def test_specifies_product_has_correct_price(self):
         self.assertEqual(D('10.00'), self.info.price.incl_tax)
 
 
-class TestFixedRateTax(ProductWithEmptyPriceTestMixin, TestCase):
+class TestFixedRateTax(TestCase):
 
-    def setUp(self):
-        self.strategy = strategy.UK()
+    def test_pricing_policy_unavailable_if_no_price_excl_tax(self):
+        product = factories.ProductFactory(stockrecords=[])
+        factories.StockRecordFactory(price_excl_tax=None, product=product)
+        info = strategy.UK().fetch_for_product(product)
+        self.assertFalse(info.price.exists)
 
 
-class TestDeferredTax(ProductWithEmptyPriceTestMixin, TestCase):
+class TestDeferredTax(TestCase):
 
-    def setUp(self):
-        self.strategy = strategy.US()
+    def test_pricing_policy_unavailable_if_no_price_excl_tax(self):
+        product = factories.ProductFactory(stockrecords=[])
+        factories.StockRecordFactory(price_excl_tax=None, product=product)
+        info = strategy.US().fetch_for_product(product)
+        self.assertFalse(info.price.exists)